Le system call rappresentano l’interfaccia fondamentale attraverso cui i programmi applicativi interagiscono con il sistema operativo. Costituiscono il ponte tra lo spazio utente (user space) e lo spazio kernel (kernel space), permettendo alle applicazioni di richiedere servizi privilegiati al sistema operativo in modo controllato e sicuro.
Per comprendere meglio questo concetto, immaginiamo un’applicazione che voglia leggere un file dal disco. L’applicazione non può accedere direttamente all’hardware del disco per motivi di sicurezza e stabilità del sistema. Invece, l’applicazione effettua una richiesta al sistema operativo attraverso una system call (in questo caso read()), e il kernel si occupa di:
Questo meccanismo garantisce che nessuna applicazione possa compromettere il sistema accedendo direttamente all’hardware o alle risorse di altri processi.
In questa lezione esploreremo in profondità il concetto di system call, il loro funzionamento interno, le diverse categorie e i meccanismi di invocazione. La comprensione delle system call è essenziale per chiunque voglia:
I moderni sistemi operativi implementano una separazione rigida tra due modalità di esecuzione:
User Space (Spazio Utente)
Lo user space è l’ambiente in cui eseguono tutte le applicazioni normali che utilizziamo quotidianamente: browser web, editor di testo, giochi, e qualsiasi altro programma. Le caratteristiche principali sono:
Modalità di esecuzione con privilegi limitati: Il processore esegue in “modalità utente”, che impedisce l’esecuzione di istruzioni pericolose. Per esempio, un’applicazione non può modificare direttamente la tabella delle pagine della memoria o disabilitare gli interrupt.
Le applicazioni utente girano in questo spazio: Ogni programma avviato dall’utente (come Firefox, LibreOffice, un script Python) opera nello user space.
Accesso limitato alle risorse hardware: Un’applicazione non può leggere direttamente dal disco, inviare pacchetti di rete, o accedere alla porta seriale. Deve sempre chiedere al kernel attraverso system call.
Non può eseguire istruzioni privilegiate: Istruzioni come HLT (ferma il processore), CLI (disabilita interrupt), o LGDT (carica tabella descriptor) causerebbero un’eccezione se tentate da user space.
Protezione della memoria: Ogni processo ha il suo spazio di indirizzi isolato. Il processo A non può leggere o scrivere la memoria del processo B. Questo è implementato attraverso la MMU (Memory Management Unit) e le page table. Se un processo tenta di accedere memoria non sua, riceve un SIGSEGV (segmentation fault).
Kernel Space (Spazio Kernel)
Il kernel space è dove risiede il cuore del sistema operativo. Solo il codice del kernel e i driver autorizzati possono eseguire in questo spazio. Le caratteristiche sono:
Modalità di esecuzione privilegiata: Il processore esegue in “modalità kernel” (o “supervisor mode”), che permette l’esecuzione di qualsiasi istruzione.
Il kernel del sistema operativo opera in questo spazio: Il codice del kernel Linux, i driver dei dispositivi, e i moduli del kernel risiedono qui.
Accesso completo a tutte le risorse hardware: Il kernel può comunicare direttamente con dischi, schede di rete, GPU, e qualsiasi altro dispositivo hardware.
Può eseguire qualsiasi istruzione del processore: Comprese istruzioni privilegiate per gestire memoria, interrupt, I/O.
Accesso a tutta la memoria fisica: Il kernel può vedere e modificare la memoria di qualsiasi processo, oltre alla propria.
Protection Rings: L’Implementazione Hardware
Questa separazione è implementata attraverso i protection ring del processore. Nelle architetture x86/x86-64 esistono 4 ring (0-3):
Quando un’applicazione in Ring 3 tenta di eseguire un’istruzione privilegiata o di accedere memoria non autorizzata, il processore genera automaticamente un’eccezione che passa il controllo al kernel. Il kernel decide quindi se terminare il processo (es. SIGSEGV per accesso memoria illegale) o se gestire la richiesta (es. page fault per memoria non ancora caricata).
Esempio pratico della separazione:
Immaginate che un’applicazione voglia scrivere un byte nel file “output.txt”:
write(fd, "A", 1) - questa è una funzione della libreria Csyscall (o int 0x80 su sistemi più vecchi)Questa danza tra user space e kernel space avviene migliaia di volte al secondo in un sistema moderno, ma è invisibile all’utente finale.
Le applicazioni utente non possono accedere direttamente a risorse critiche come:
File system: I file su disco contengono dati potenzialmente sensibili. Se ogni applicazione potesse leggere qualsiasi file, la sicurezza del sistema sarebbe compromessa. Ad esempio, un gioco potrebbe leggere le vostre password salvate nel browser, o un editor di testo potrebbe modificare file di sistema critici causando il crash del sistema operativo.
Dispositivi di I/O (disco, rete, tastiera, schermo): L’accesso diretto all’hardware richiede conoscenza dei dettagli specifici del dispositivo. Diverse schede di rete hanno protocolli di comunicazione diversi, diversi dischi hanno controller diversi. Se ogni applicazione dovesse sapere come parlare direttamente con l’hardware, dovremmo riscrivere ogni programma per ogni nuovo modello di hardware. Inoltre, se due applicazioni tentassero di scrivere sullo schermo contemporaneamente senza coordinazione, vedremmo un guazzabuglio incomprensibile di caratteri mescolati.
Memoria fisica: La memoria RAM è una risorsa condivisa tra tutti i processi. Se un’applicazione potesse accedere a qualsiasi indirizzo di memoria fisica, potrebbe leggere i dati privati di altre applicazioni (come le password che state digitando in un’altra finestra) o peggio, sovrascriverli causando crash o comportamenti imprevedibili. Immaginate un bug in un videogioco che corrompe i documenti aperti nel vostro word processor!
Altri processi: Ogni processo dovrebbe essere isolato dagli altri. Un processo non dovrebbe poter terminare arbitrariamente un altro processo, leggerne la memoria, o modificarne l’esecuzione. Senza questa protezione, un programma malevolo potrebbe terminare il vostro antivirus o manipolare l’esecuzione del vostro browser per rubare dati.
Timer hardware: Il timer hardware del sistema genera interrupt a intervalli regolari (tipicamente ogni 1-10 millisecondi) e è fondamentale per lo scheduling dei processi. Se un’applicazione potesse disabilitare o riprogrammare il timer, potrebbe monopolizzare la CPU impedendo l’esecuzione di altri programmi, incluso il sistema operativo stesso.
Vantaggi della mediazione tramite system call:
Vediamo ora perché avere il kernel come mediatore attraverso le system call è così importante:
Sicurezza: Il kernel può verificare ogni richiesta e negare accessi non autorizzati. Quando chiamate open("/etc/shadow") (il file che contiene le password degli utenti in Linux), il kernel controlla se il vostro processo ha i permessi necessari. Se siete un utente normale, la chiamata fallirà con errore EACCES (Permission Denied). Questo controllo avviene nel kernel, dove voi non potete bypassarlo. Se le applicazioni potessero accedere direttamente ai file, questo controllo sarebbe impossibile da applicare.
Stabilità: Errori nelle applicazioni non possono corrompere il sistema. Se il vostro programma ha un bug e tenta di accedere a un puntatore NULL, ricevete un SIGSEGV (segmentation fault) e il vostro processo termina, ma il sistema operativo continua a funzionare normalmente. Questo perché il kernel rileva l’accesso illegale alla memoria e termina il processo prima che possa fare danni. Se invece i programmi potessero accedere direttamente alla memoria fisica, un accesso errato potrebbe corrompere le strutture dati del kernel stesso, causando il crash dell’intero sistema.
Astrazione: Le applicazioni non devono conoscere i dettagli hardware. Quando scrivete write(fd, buffer, size), non vi importa se state scrivendo su un hard disk SATA, un SSD NVMe, un file su una condivisione di rete NFS, o su un dispositivo USB. Il kernel nasconde tutti questi dettagli e presenta un’interfaccia uniforme. Lo stesso codice funziona indipendentemente dall’hardware sottostante. Questa è un’astrazione potentissima che semplifica enormemente la programmazione.
Portabilità: Lo stesso codice funziona su hardware diverso. Un programma compilato per Linux su architettura x86-64 che usa system call POSIX standard può essere ricompilato e funzionare su ARM, RISC-V, o qualsiasi altra architettura supportata da Linux, senza modifiche al codice. Il kernel si occupa di tradurre le richieste standard in operazioni specifiche per l’hardware particolare. Senza questa astrazione, dovreste riscrivere parti del vostro codice per ogni piattaforma.
Condivisione controllata: Il kernel coordina l’accesso alle risorse condivise. Se due processi tentano di scrivere sullo stesso file contemporaneamente, il kernel serializza le operazioni per evitare corruzione dei dati. Se due processi vogliono usare la scheda di rete, il kernel intercala i loro pacchetti assicurando che arrivino correttamente a destinazione. Senza questa coordinazione centrale, il caos sarebbe inevitabile – immaginate due programmi che tentano di disegnare sullo schermo nello stesso momento, o due processi che cercano di modificare lo stesso file simultaneamente!
Questa architettura crea una chiara separazione tra “cosa” volete fare (aprire un file, leggere dati, inviare un pacchetto di rete) e “come” viene fatto a livello hardware. Voi, come programmatori, dichiarate l’intenzione attraverso le system call, e il kernel si occupa dei dettagli implementativi. Questa separazione è una delle idee fondamentali che rendono possibili i moderni sistemi operativi multitasking e multiutente.
Una system call è una chiamata programmata a un servizio fornito dal kernel del sistema operativo. È l’unico meccanismo attraverso cui un programma in user space può richiedere al kernel di eseguire operazioni privilegiate per suo conto.
Proviamo a comprendere meglio cosa significa questa definizione. Quando scriviamo un programma in C e utilizziamo una funzione come read() per leggere da un file, potremmo pensare che sia una semplice funzione di libreria come strlen() o printf(). In realtà, read() è molto più di questo: è un punto di ingresso verso il kernel del sistema operativo. Non è il nostro codice che accede fisicamente al disco rigido – questo sarebbe impossibile e pericoloso. È il kernel che, su nostra richiesta tramite la system call read(), esegue l’operazione privilegiata di accedere all’hardware.
Questa distinzione è fondamentale: le funzioni normali operano interamente nello spazio della nostra applicazione, usando solo le risorse già assegnate al nostro programma. Le system call, invece, richiedono l’intervento del kernel, che ha accesso completo all’hardware e alle risorse di sistema.
Caratteristiche principali:
È un’operazione atomica dal punto di vista dell’applicazione: Quando chiamiamo una system call, dal nostro punto di vista l’intera operazione avviene in un singolo passo. Non vediamo i dettagli interni di come il kernel gestisce la nostra richiesta – vediamo solo che chiamiamo read() e poi riceviamo i dati (o un errore). L’atomicità qui non significa che l’operazione sia instantanea o che non possa essere interrotta da segnali, ma che concettualmente trattiamo la system call come un’unità indivisibile di lavoro dal punto di vista dell’interfaccia di programmazione.
Comporta un cambio di contesto (context switch) da user mode a kernel mode: Questo è uno dei costi nascosti delle system call. Quando il processore passa da user mode a kernel mode, deve salvare lo stato corrente del programma (tutti i registri della CPU, il program counter, lo stack pointer, e altre informazioni di stato), passare alla modalità privilegiata, eseguire il codice del kernel, e poi ripristinare tutto lo stato quando ritorna. Questo processo è chiamato “context switch” e rappresenta un overhead significativo rispetto a una semplice chiamata a funzione.
Ha un costo in termini di performance (overhead): Come conseguenza del context switch, ogni system call ha un costo misurato in centinaia di nanosecondi, mentre una chiamata a funzione normale costa pochi nanosecondi. Questo significa che una system call è circa 50-100 volte più “costosa” di una funzione normale. Per questo motivo, i programmatori esperti cercano di minimizzare il numero di system call, ad esempio raggruppando più operazioni di I/O in un’unica chiamata quando possibile.
È l’interfaccia definita e documentata tra applicazioni e sistema operativo: Le system call costituiscono un contratto tra il sistema operativo e le applicazioni. Il kernel Linux garantisce che le system call mantengano la loro interfaccia stabile nel tempo – un programma compilato 10 anni fa continuerà a funzionare perché le system call che usa non cambiano. Questo è molto diverso dalle funzioni interne del kernel, che possono cambiare liberamente tra una versione e l’altra. Le system call sono documentate nelle pagine di manuale (man pages) della sezione 2, accessibili con comandi come man 2 open.
Quando un’applicazione invoca una system call, avviene una sequenza complessa di operazioni che coinvolgono sia il software (libreria C, kernel) che l’hardware (processore, MMU). Analizziamo ogni passo in dettaglio:
1. Preparazione dei Parametri
Tutto inizia con una semplice chiamata in linguaggio C:
// L'applicazione prepara i parametri
int fd = open("/etc/passwd", O_RDONLY);
Analizziamo questa riga:
open è una funzione della libreria C (libc) che funge da “wrapper” attorno alla vera system call"/etc/passwd" è il path del file da aprire - un puntatore a una stringa nello spazio di memoria del processoO_RDONLY è una costante (valore 0) che specifica “apri in sola lettura”int fd) che sarà il file descriptor, o -1 in caso di erroreA questo punto siamo ancora completamente nello user space. La variabile fd non è ancora stata inizializzata e nessuna interazione con il kernel è avvenuta.
2. Invocazione della Wrapper Function
La libreria C (libc) fornisce una funzione wrapper per ogni system call. Questo wrapper ha tre compiti fondamentali:
a) Carica i parametri nei registri appropriati: Su x86-64, i parametri vengono caricati in registri specifici secondo la calling convention:
%rdi: primo parametro (pathname: "/etc/passwd")%rsi: secondo parametro (flags: O_RDONLY)%rdx: terzo parametro (mode: non usato in questo caso)%rax: numero della system call (per open è 2 su x86-64)b) Carica il numero della system call: Ogni system call ha un numero univoco. Il wrapper carica questo numero nel registro %rax.
c) Esegue l’istruzione trap/interrupt: Questa è l’istruzione che causa il passaggio da user mode a kernel mode.
3. Trap/Interrupt Software
L’istruzione chiave che causa il cambio di modalità è:
; Su x86-64, viene eseguita l'istruzione
syscall ; oppure int 0x80 su sistemi più vecchi
Cosa succede esattamente quando viene eseguita syscall:
a) Salvataggio dello stato: Il processore salva automaticamente:
%rip (instruction pointer) - l’indirizzo della prossima istruzione da eseguire dopo il ritorno%rflags - i flag del processore (zero flag, carry flag, etc.)%rsp (stack pointer) dello user spaceb) Cambio di privilegio: Il processore passa da Ring 3 (user mode) a Ring 0 (kernel mode). Questo è un cambio hardware - il bit CPL (Current Privilege Level) nel registro CS viene impostato a 0.
c) Salto all’handler: Il processore salta all’indirizzo del system call handler nel kernel. Questo indirizzo è preconfigurato in un registro speciale chiamato MSR (Model Specific Register) IA32_LSTAR.
d) Cambio stack: Il processore passa dallo stack utente allo stack kernel. Ogni processo ha due stack: uno user space e uno kernel space.
4. System Call Handler nel Kernel
Una volta nel kernel, entra in gioco il system call handler. Ecco come appare (pseudocodice semplificato del kernel Linux):
// Pseudocodice del kernel
SYSCALL_DEFINE3(open, const char __user *, filename,
int, flags, umode_t, mode) {
// Validazione parametri
// Controlli di sicurezza
// Esecuzione della logica
// Ritorno del risultato
}
Analizziamo cosa fa questo codice:
a) Definizione della system call:
SYSCALL_DEFINE3 è una macro che definisce una system call con 3 parametriopen identifica questa system callconst char __user * significa “puntatore a stringa costante nello user space”b) Validazione parametri - Passo cruciale per la sicurezza:
// Il kernel deve validare il puntatore filename
if (!access_ok(filename, VERIFY_READ)) {
return -EFAULT; // Indirizzo non valido
}
// Copia la stringa dallo user space al kernel space
// (non possiamo fidarci di puntatori user space)
char *kernel_filename = getname(filename);
if (IS_ERR(kernel_filename)) {
return PTR_ERR(kernel_filename);
}
Perché questa validazione è necessaria? Un’applicazione malintenzionata potrebbe passare:
c) Controlli di permessi:
// Verifica che l'utente abbia permessi per aprire il file
struct inode *inode = path_lookup(kernel_filename);
if (!inode) {
return -ENOENT; // File non esiste
}
if (!inode_permission(inode, MAY_READ)) {
return -EACCES; // Permesso negato
}
Il kernel controlla:
d) Esecuzione della logica:
// Alloca un file descriptor
int fd = get_unused_fd_flags(flags);
if (fd < 0) {
return fd; // Troppi file aperti
}
// Apre effettivamente il file
struct file *filp = do_filp_open(kernel_filename, flags, mode);
if (IS_ERR(filp)) {
put_unused_fd(fd);
return PTR_ERR(filp);
}
// Installa il file descriptor nella tabella del processo
fd_install(fd, filp);
Cosa succede qui:
get_unused_fd_flags: trova il primo file descriptor libero (es. 3, 4, 5…)do_filp_open: fa il vero lavoro di apertura del file (comunica con il filesystem, carica inode, etc.)fd_install: collega il file descriptor alla struttura file nella tabella dei file descriptor del processoe) Preparazione del risultato:
// Ritorna il file descriptor (numero positivo)
// oppure codice di errore negativo
return fd;
Il kernel prepara il valore di ritorno nel registro %rax. Se tutto è andato bene, %rax contiene il file descriptor (es. 3). Se c’è stato un errore, %rax contiene un numero negativo (es. -ENOENT = -2).
5. Ritorno allo User Space
Il kernel ha completato il suo lavoro. Ora deve restituire il controllo all’applicazione:
a) Ripristino dello stato del processore:
sysret (su x86-64)%rip, %rflags, %rsp dai valori salvati%raxb) Cambio di privilegio: Il processore passa da Ring 0 (kernel mode) a Ring 3 (user mode)
c) Ritorno all’applicazione: L’esecuzione riprende dall’istruzione immediatamente successiva a syscall
6. Gestione del Risultato nella Wrapper Function
La wrapper function della libc riceve il valore in %rax e lo elabora:
// Pseudocodice della wrapper function nella libc
long sys_open(const char *pathname, int flags, mode_t mode) {
long result;
// Esegue la system call (in assembly)
asm volatile(
"mov %1, %%rdi\n" // pathname in %rdi
"mov %2, %%rsi\n" // flags in %rsi
"mov %3, %%rdx\n" // mode in %rdx
"mov $2, %%rax\n" // numero syscall open (2) in %rax
"syscall\n" // esegue system call
"mov %%rax, %0\n" // risultato in result
: "=r" (result)
: "r" (pathname), "r" (flags), "r" (mode)
: "%rax", "%rdi", "%rsi", "%rdx"
);
// Gestisce il risultato
if (result < 0) {
// Errore: imposta errno al valore positivo dell'errore
errno = -result;
return -1;
}
// Successo: ritorna il file descriptor
return result;
}
Cosa fa questo codice:
result < 0, c’è stato un erroreerrno viene impostato al codice di errore (positivo)7. Gestione nell’Applicazione
Finalmente, il controllo torna al nostro codice C:
int fd = open("/etc/passwd", O_RDONLY);
// A questo punto fd contiene il risultato
if (fd == -1) {
// Errore: errno è stato impostato dalla wrapper function
// Stampa il messaggio di errore corrispondente a errno
perror("open");
// oppure
printf("Errore: %s\n", strerror(errno));
// errno potrebbe essere:
// ENOENT (2) - file non esiste
// EACCES (13) - permesso negato
// EMFILE (24) - troppi file aperti
// etc.
exit(EXIT_FAILURE);
}
// Successo: fd contiene un numero >= 0 (es. 3)
printf("File aperto con fd = %d\n", fd);
// Ora possiamo usare fd per leggere dal file
char buffer[100];
ssize_t n = read(fd, buffer, sizeof(buffer));
// ... read farà un'altra system call con lo stesso processo ...
Riepilogo del Viaggio Completo:
open("/etc/passwd", O_RDONLY) nel codice CsyscallTutto questo processo avviene in microsecondi, ma comprenderlo è fondamentale per capire come funzionano i sistemi operativi moderni!
User Space Kernel Space
│ │
│ 1. Chiamata a open() │
│ (wrapper libc) │
│ │
│ 2. Caricamento parametri │
│ nei registri │
│ │
│ 3. syscall/int 0x80 │
├────────────────────────────────────┤
│ │ Context Switch
│ 4. Handler riceve
│ controllo
│ │
│ 5. Valida parametri
│ │
│ 6. Controlla permessi
│ │
│ 7. Esegue operazione
│ │
│ 8. Prepara risultato
├────────────────────────────────────┤
│ │ Context Switch
│ 9. Riceve risultato │
│ │
│ 10. Imposta errno se errore │
│ │
│ 11. Ritorna al chiamante │
│ │
Ogni system call è identificata da un numero univoco. Questo numero è ciò che il kernel usa per capire quale servizio l’applicazione sta richiedendo. Quando eseguite l’istruzione syscall, il processore passa il controllo al kernel, e il kernel guarda nel registro RAX (su x86-64) per vedere quale numero di system call è stato richiesto. Basandosi su questo numero, il kernel salta alla funzione appropriata che implementa quella particolare system call.
Questi numeri sono definiti in file header del kernel e sono specifici dell’architettura. Questo significa che lo stesso servizio (ad esempio, “aprire un file”) può avere numeri diversi su architetture diverse. Questo è il motivo per cui non dovreste mai chiamare le system call direttamente usando il numero – usate sempre le funzioni wrapper della libc che si occupano di questa conversione in modo portabile.
Vediamo alcuni esempi su Linux x86-64:
// Alcuni esempi di numeri di system call (x86-64)
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5
#define __NR_lstat 6
#define __NR_poll 7
#define __NR_fork 57
#define __NR_execve 59
#define __NR_exit 60
Notate alcuni dettagli interessanti. I numeri più bassi (0-10) sono assegnati alle system call più comuni e fondamentali come read, write, open, close. Questo non è casuale – storicamente, questi numeri sono stati assegnati alle operazioni più frequenti per rendere il codice del kernel leggermente più efficiente (anche se l’impatto in termini di performance è minimo sui processori moderni).
Il numero 2 è assegnato a open, una delle system call più usate. Ogni volta che un programma apre un file – che succede centinaia o migliaia di volte durante l’esecuzione di applicazioni complesse – il numero 2 viene caricato in RAX e l’istruzione syscall viene eseguita.
fork ha il numero 57 e execve il 59. Questi numeri più alti riflettono il fatto che sono state aggiunte al sistema in un secondo momento rispetto alle operazioni di I/O basilari. La numerazione delle system call riflette un po’ la storia evolutiva di Unix.
Differenze tra architetture:
Questi numeri sono specifici dell’architettura e possono variare significativamente tra:
x86 (32-bit) vs x86-64 (64-bit): Quando Linux è stato portato a 64 bit, i progettisti hanno avuto l’opportunità di riorganizzare i numeri delle system call in modo più logico. Per questo motivo, molte system call hanno numeri diversi tra x86 e x86-64. Ad esempio, open è numero 5 su x86 32-bit ma numero 2 su x86-64.
ARM vs ARM64: Architetture ARM hanno le loro convenzioni. ARM a 32 bit (usato in molti smartphone più vecchi e dispositivi embedded) usa un insieme di numeri completamente diverso. ARM64 (usato negli smartphone moderni e nei nuovi Mac con chip Apple Silicon) ha di nuovo una numerazione diversa.
RISC-V: Questa architettura relativamente nuova ha la sua tabella di numeri di system call, progettata imparando dall’esperienza delle architetture precedenti.
Altre architetture: MIPS, PowerPC, s390 (mainframe IBM), SPARC, e altre architetture supportate da Linux hanno tutte le loro tavole di numeri di system call.
Questa variabilità è gestita dalla libc (libreria C standard), che fornisce le funzioni wrapper come read(), write(), open(). Quando compilate il vostro programma per una specifica architettura, la libc corretta viene linkata, e le funzioni wrapper useranno i numeri appropriati per quella architettura. Questo è un altro livello di astrazione che rende il codice portabile.
Stabilità dell’ABI (Application Binary Interface):
Anche se i numeri possono essere diversi tra architetture, c’è una regola ferrea: per una data architettura, i numeri delle system call NON cambiano mai. Questo fa parte della garanzia di stabilità dell’ABI di Linux. Un programma compilato per x86-64 nel 2010 funzionerà ancora su un kernel Linux del 2026 perché i numeri delle system call sono rimasti gli stessi. Questo permette di eseguire software binario vecchio di decenni su kernel moderni – una caratteristica cruciale per la compatibilità all’indietro.
Nuove system call vengono aggiunte assegnando loro nuovi numeri (tipicamente in ordine crescente), ma i numeri esistenti non vengono mai riutilizzati o modificati. Se una system call diventa obsoleta, il suo numero rimane riservato per sempre, e chiamarla ritorna semplicemente un errore (tipicamente ENOSYS - “Function not implemented”).
Potete vedere la lista completa di tutte le system call disponibili sul vostro sistema guardando il file /usr/include/asm/unistd_64.h (su x86-64) o usando comandi come ausyscall --dump se avete installato il pacchetto auditd.
Le system call possono essere organizzate in diverse categorie funzionali:
Queste system call permettono di creare, controllare e terminare processi. Sono alla base di qualsiasi programma che necessiti di eseguire task in parallelo o lanciare altri programmi.
System Call Principali:
// Creazione processi
pid_t fork(void); // Crea processo figlio
pid_t vfork(void); // Fork ottimizzato per exec
int clone(int (*fn)(void *), ...); // Creazione thread/processo flessibile
// Esecuzione programmi
int execve(const char *pathname, char *const argv[],
char *const envp[]); // Esegue nuovo programma
// Terminazione
void exit(int status); // Termina processo
void _exit(int status); // Termina senza cleanup
// Attesa
pid_t wait(int *status); // Attende figlio
pid_t waitpid(pid_t pid, int *status, int options);
// Informazioni processo
pid_t getpid(void); // PID corrente
pid_t getppid(void); // PID del padre
uid_t getuid(void); // User ID reale
uid_t geteuid(void); // User ID effettivo
Spiegazione delle system call:
fork(): Crea una copia esatta del processo chiamante. Il processo figlio è identico al padre, tranne per il PID e il valore di ritorno di fork(). Nel padre, fork() ritorna il PID del figlio; nel figlio, ritorna 0.
vfork(): Simile a fork(), ma più efficiente quando subito dopo si chiama exec(). Il padre viene sospeso fino a che il figlio non chiama exec() o exit(). Usare con cautela, è deprecato in favore di fork() con Copy-on-Write.
clone(): System call di basso livello usata per creare sia processi che thread. Permette di specificare esattamente cosa condividere tra padre e figlio (memoria, file descriptor, etc.).
execve(): Sostituisce l’immagine del processo corrente con un nuovo programma. Carica il programma specificato da pathname e lo esegue con gli argomenti argv e l’ambiente envp.
exit() vs _exit(): exit() esegue cleanup (chiude stream, chiama atexit handlers) prima di terminare. _exit() termina immediatamente senza cleanup. Generalmente si usa exit().
wait(): Blocca il processo chiamante fino a che uno qualsiasi dei suoi figli termina. Ritorna il PID del figlio terminato e salva lo status in *status.
waitpid(): Versione più flessibile di wait(). Permette di specificare quale figlio attendere e se bloccare o fare polling.
getpid() / getppid(): Ritornano rispettivamente il PID del processo corrente e del padre. Non possono fallire.
getuid() / geteuid(): Ritornano lo User ID reale ed effettivo. Il real UID identifica chi ha lanciato il processo; l’effective UID determina i permessi.
Esempio completo di utilizzo:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
// fork() crea un nuovo processo
pid_t pid = fork();
// Controlliamo il valore di ritorno di fork()
if (pid == -1) {
// fork() ha fallito - possibili motivi:
// - Limite massimo processi raggiunto
// - Memoria insufficiente
// - Altri limiti di sistema
perror("fork failed");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// ===== CODICE DEL PROCESSO FIGLIO =====
// Qui pid == 0, quindi siamo nel figlio
// getpid() ritorna il PID del processo corrente (figlio)
// getppid() ritorna il PID del padre
printf("Figlio: PID = %d, PPID = %d\n", getpid(), getppid());
// getuid() ritorna lo User ID reale (chi ha lanciato il programma)
// geteuid() ritorna lo User ID effettivo (per permessi)
// Normalmente sono uguali, a meno che il programma non sia setuid
printf("Figlio: UID = %d, EUID = %d\n", getuid(), geteuid());
// Il figlio termina con exit code 42
// Questo valore sarà disponibile al padre tramite wait()
exit(42);
// NOTA: Codice dopo exit() non viene mai eseguito
printf("Questa riga non verrà mai stampata!\n");
} else {
// ===== CODICE DEL PROCESSO PADRE =====
// Qui pid > 0, contiene il PID del figlio appena creato
printf("Padre: PID = %d, figlio PID = %d\n", getpid(), pid);
// Il padre attende la terminazione del figlio
int status; // Variabile per ricevere lo status di terminazione
// wait() blocca fino a che un figlio termina
// Ritorna il PID del figlio terminato
pid_t terminated = wait(&status);
// Verifichiamo come è terminato il figlio
if (WIFEXITED(status)) {
// Il figlio è terminato normalmente (con exit() o return)
// WEXITSTATUS estrae l'exit code (42 nel nostro caso)
int exit_code = WEXITSTATUS(status);
printf("Figlio %d terminato con status %d\n",
terminated, exit_code);
} else if (WIFSIGNALED(status)) {
// Il figlio è stato terminato da un segnale
// WTERMSIG estrae il numero del segnale
int signal = WTERMSIG(status);
printf("Figlio %d terminato da segnale %d\n",
terminated, signal);
}
}
// Il padre esce con successo
return 0;
}
Analisi dettagliata del codice:
Linea: pid_t pid = fork();
pid contiene il PID del figlio (numero > 0, es. 1234)pid contiene 0pid contiene -1 e errno è impostatoLinea: if (pid == -1)
EAGAIN: Limite di processi per utente raggiuntoENOMEM: Memoria insufficiente per il nuovo processoperror("fork failed") stampa: “fork failed: <descrizione errore>”exit(EXIT_FAILURE) termina con codice di errore (1)Blocco figlio: if (pid == 0)
getpid() ritorna il PID del figlio (es. 1235)getppid() ritorna il PID del padre (es. 1234)Linea: exit(42)
Blocco padre: else
pid contiene il PID del figlio (numero positivo)Linea: pid_t terminated = wait(&status);
wait() blocca (sospende) il padre fino a che un figlio termina&status è un puntatore dove wait() salverà informazioni sulla terminazioneLinea: if (WIFEXITED(status))
WIFEXITED è una macro che controlla se il figlio è terminato normalmentestatus è un intero che codifica diverse informazioni:
WIFEXITED, WEXITSTATUS, etc. estraggono queste informazioniLinea: int exit_code = WEXITSTATUS(status);
WIFEXITED(status) è veroOutput tipico del programma:
Padre: PID = 1234, figlio PID = 1235
Figlio: PID = 1235, PPID = 1234
Figlio: UID = 1000, EUID = 1000
Figlio 1235 terminato con status 42
Ordine non deterministico:
L’ordine delle stampe non è garantito! Potremmo vedere:
Figlio: PID = 1235, PPID = 1234
Padre: PID = 1234, figlio PID = 1235
Figlio: UID = 1000, EUID = 1000
Figlio 1235 terminato con status 42
Questo perché padre e figlio eseguono in parallelo, e lo scheduler del kernel decide chi esegue per primo. Questo è un concetto fondamentale della programmazione concorrente!
Le system call per la gestione file sono tra le più usate in programmazione di sistema. Permettono di aprire, leggere, scrivere, e manipolare file sul filesystem.
System Call Principali:
// Apertura e chiusura
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
int close(int fd);
// Lettura e scrittura
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
// Posizionamento
off_t lseek(int fd, off_t offset, int whence);
// Duplicazione file descriptor
int dup(int oldfd);
int dup2(int oldfd, int newfd);
// Informazioni file
int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);
// Controllo file
int fcntl(int fd, int cmd, ... /* arg */);
int ioctl(int fd, unsigned long request, ...);
// Sincronizzazione
int fsync(int fd);
int fdatasync(int fd);
void sync(void);
Spiegazione dettagliata delle system call:
open(pathname, flags) / open(pathname, flags, mode)
pathname: path assoluto o relativo del fileflags: modalità di apertura (OR bit-a-bit di):
O_RDONLY (0): solo letturaO_WRONLY (1): solo scritturaO_RDWR (2): lettura e scritturaO_CREAT: crea il file se non esiste (richiede mode)O_TRUNC: tronca file esistente a lunghezza 0O_APPEND: scritture vanno sempre in fondo al fileO_EXCL: con O_CREAT, fallisce se file esiste (per atomicità)O_CLOEXEC: chiude automaticamente il fd durante exec()mode: permessi per file nuovo (es. 0644 = rw-r–r--)close(fd)
read(fd, buf, count)
count byte dal file in bufcount byte se:
write(fd, buf, count)
count byte da buf nel filecount byte (es. disco pieno)lseek(fd, offset, whence)
whence può essere:
SEEK_SET: offset assoluto dall’inizioSEEK_CUR: offset relativo alla posizione correnteSEEK_END: offset relativo alla fine del filestat(pathname, statbuf) / fstat(fd, statbuf) / lstat(pathname, statbuf)
stat: segue i symbolic linklstat: non segue i symbolic link (restituisce info sul link stesso)fstat: opera su un fd già apertostruct stat con:
st_mode: tipo file e permessist_size: dimensione in bytest_ino: numero inodest_uid, st_gid: proprietario e gruppost_atime, st_mtime, st_ctime: timestampfsync(fd)
fdatasync: simile ma non sincronizza alcuni metadata (più veloce)Esempio completo di gestione file:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h> // per open, O_* flags
#include <unistd.h> // per read, write, close
#include <string.h> // per strlen
#include <sys/stat.h> // per fstat, struct stat
int main() {
const char *filename = "esempio.txt";
const char *data = "Hello, System Calls!\n";
// ===========================================
// 1. Creazione/Apertura file per SCRITTURA
// ===========================================
// Flags usati:
// O_WRONLY: apri in sola scrittura
// O_CREAT: crea il file se non esiste
// O_TRUNC: se esiste, tronca a lunghezza 0 (cancella contenuto)
// Mode 0644: rw-r--r-- (owner può leggere/scrivere, altri solo leggere)
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
// open() ha fallito - possibili motivi:
// EACCES: permessi insufficienti
// ENOENT: directory nel path non esiste
// EMFILE: troppi file aperti nel processo
// ENFILE: troppi file aperti nel sistema
perror("open");
exit(EXIT_FAILURE);
}
// fd ora contiene il file descriptor (tipicamente 3)
// I fd 0, 1, 2 sono riservati per stdin, stdout, stderr
printf("File aperto, fd = %d\n", fd);
// ===========================================
// 2. Scrittura nel file
// ===========================================
// Scriviamo la stringa nel file
// strlen(data) calcola la lunghezza (21 byte, incluso \n)
ssize_t bytes_written = write(fd, data, strlen(data));
if (bytes_written == -1) {
// write() ha fallito - possibili motivi:
// ENOSPC: disco pieno
// EDQUOT: quota disco superata
// EIO: errore I/O hardware
// EINTR: interrotto da segnale
perror("write");
close(fd); // Chiudiamo fd prima di uscire
exit(EXIT_FAILURE);
}
// bytes_written potrebbe essere < strlen(data) in caso di scrittura parziale
// In codice production, bisognerebbe gestire questo caso con un loop
printf("Scritti %zd bytes\n", bytes_written);
// ===========================================
// 3. Sincronizzazione (flush su disco)
// ===========================================
// fsync() forza la scrittura fisica su disco
// Senza fsync(), i dati potrebbero essere solo nel buffer cache
// e perdersi in caso di crash/power loss
if (fsync(fd) == -1) {
perror("fsync");
// Non usciamo, non è fatale, ma i dati potrebbero non essere persistenti
}
printf("Dati sincronizzati su disco\n");
// ===========================================
// 4. Ottenimento informazioni file
// ===========================================
// fstat opera su un fd già aperto
struct stat file_stat;
if (fstat(fd, &file_stat) == -1) {
perror("fstat");
} else {
// file_stat ora contiene tutte le informazioni sul file
// st_size: dimensione in byte
printf("Dimensione file: %ld bytes\n", file_stat.st_size);
// st_mode contiene tipo file e permessi
// & 0777 estrae solo i permessi (ignora tipo file)
// %o stampa in ottale (es. 644)
printf("Permessi: %o\n", file_stat.st_mode & 0777);
// st_ino: numero inode (identificatore univoco nel filesystem)
printf("Inode: %lu\n", file_stat.st_ino);
// Altri campi disponibili:
// file_stat.st_uid: user ID proprietario
// file_stat.st_gid: group ID
// file_stat.st_mtime: timestamp ultima modifica
// file_stat.st_atime: timestamp ultimo accesso
// file_stat.st_ctime: timestamp ultimo cambio stato
// file_stat.st_nlink: numero hard link
// file_stat.st_dev: device ID
// file_stat.st_blksize: dimensione blocco ottimale per I/O
// file_stat.st_blocks: numero blocchi allocati
}
// ===========================================
// 5. Chiusura file
// ===========================================
if (close(fd) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
printf("File chiuso\n");
// Dopo close(), fd non è più valido
// Tentare di usare fd ora causerebbe EBADF (bad file descriptor)
// ===========================================
// 6. Riapertura per LETTURA
// ===========================================
// Ora apriamo lo stesso file in sola lettura
fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open for reading");
exit(EXIT_FAILURE);
}
printf("File riaperto per lettura, fd = %d\n", fd);
// ===========================================
// 7. Lettura dal file
// ===========================================
// Buffer per contenere i dati letti
// -1 per lasciare spazio al null terminator
char buffer[100];
// Leggiamo dal file
// read() può ritornare meno di sizeof(buffer)-1 byte
ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
// read() ha fallito - possibili motivi:
// EIO: errore I/O
// EINTR: interrotto da segnale
// EISDIR: fd è una directory
perror("read");
} else if (bytes_read == 0) {
// EOF (End Of File) - file vuoto o già letto tutto
printf("EOF raggiunto\n");
} else {
// Lettura riuscita!
// Aggiungiamo null terminator per trattare come stringa
buffer[bytes_read] = '\0';
// Stampiamo il contenuto
// %s interpreta buffer come stringa C
printf("Letto: %s", buffer); // No \n perché buffer già contiene \n
printf("Numero byte letti: %zd\n", bytes_read);
}
// ===========================================
// 8. Chiusura finale
// ===========================================
close(fd);
printf("Programma terminato con successo\n");
return 0;
}
Output tipico del programma:
File aperto, fd = 3
Scritti 21 bytes
Dati sincronizzati su disco
Dimensione file: 21 bytes
Permessi: 644
Inode: 123456
File chiuso
File riaperto per lettura, fd = 3
Letto: Hello, System Calls!
Numero byte letti: 21
Programma terminato con successo
Dettagli importanti:
File Descriptor: Sono sempre i numeri più bassi disponibili. Se chiudiamo il fd 3 e riapriamo un file, probabilmente otterremo di nuovo 3.
File Offset: Ogni fd aperto ha un offset associato (posizione corrente nel file). read() e write() avanzano automaticamente questo offset.
Buffering: Il kernel mantiene una cache (page cache) dei dati del file. write() scrive nella cache, non necessariamente sul disco. fsync() forza la scrittura fisica.
Atomicità: open() con O_CREAT | O_EXCL è atomico - garantisce che solo un processo crei il file.
Errori parziali: read() e write() possono trasferire meno byte del richiesto. Codice production deve gestire questo con loop.
Esempio di lettura robusta con loop:
// Legge esattamente count byte (o EOF o errore)
ssize_t read_full(int fd, void *buf, size_t count) {
size_t total = 0;
char *ptr = buf;
while (total < count) {
ssize_t n = read(fd, ptr + total, count - total);
if (n == -1) {
if (errno == EINTR) {
// Interrotto da segnale, riprova
continue;
}
// Errore reale
return -1;
}
if (n == 0) {
// EOF raggiunto
break;
}
total += n;
}
return total;
}
Questo pattern gestisce correttamente:
System call per operazioni su directory.
// Creazione e rimozione
int mkdir(const char *pathname, mode_t mode);
int rmdir(const char *pathname);
// Cambio directory
int chdir(const char *path);
int fchdir(int fd);
char *getcwd(char *buf, size_t size);
// Lettura directory (obsolete, usare readdir())
int getdents(unsigned int fd, struct linux_dirent *dirp,
unsigned int count);
// Link
int link(const char *oldpath, const char *newpath);
int unlink(const char *pathname);
int symlink(const char *target, const char *linkpath);
int readlink(const char *pathname, char *buf, size_t bufsiz);
// Rinomina
int rename(const char *oldpath, const char *newpath);
Esempio di navigazione directory:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
int main() {
char cwd[1024];
// Ottieni directory corrente
if (getcwd(cwd, sizeof(cwd)) != NULL) {
printf("Directory corrente: %s\n", cwd);
} else {
perror("getcwd");
exit(EXIT_FAILURE);
}
// Crea una nuova directory
const char *new_dir = "test_syscalls";
if (mkdir(new_dir, 0755) == -1) {
if (errno != EEXIST) { // Ignora se già esistente
perror("mkdir");
exit(EXIT_FAILURE);
}
}
printf("Directory '%s' creata\n", new_dir);
// Cambia directory
if (chdir(new_dir) == -1) {
perror("chdir");
exit(EXIT_FAILURE);
}
// Verifica cambio
if (getcwd(cwd, sizeof(cwd)) != NULL) {
printf("Nuova directory corrente: %s\n", cwd);
}
// Torna alla directory originale
if (chdir("..") == -1) {
perror("chdir back");
exit(EXIT_FAILURE);
}
// Rimuovi directory (deve essere vuota)
if (rmdir(new_dir) == -1) {
perror("rmdir");
exit(EXIT_FAILURE);
}
printf("Directory '%s' rimossa\n", new_dir);
return 0;
}
System call per allocazione e gestione della memoria.
// Allocazione memoria
void *brk(void *addr); // Modifica program break
void *sbrk(intptr_t increment); // Incrementa program break
// Memory mapping
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset); // Mappa file/memoria
int munmap(void *addr, size_t length); // Rilascia mapping
// Protezione memoria
int mprotect(void *addr, size_t len, int prot);
// Operazioni su memoria mappata
int msync(void *addr, size_t length, int flags);
int madvise(void *addr, size_t length, int advice);
// Shared memory (System V)
int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
Esempio di memory mapping con spiegazione dettagliata:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <string.h>
int main() {
const char *filename = "mapped_file.txt";
const char *data = "Contenuto mappato in memoria!";
size_t data_len = strlen(data);
// ===========================================
// 1. Crea file
// ===========================================
// O_RDWR: leggi e scrivi
// O_CREAT: crea se non esiste
// O_TRUNC: tronca se esiste
// 0644: rw-r--r--
int fd = open(filename, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
printf("File '%s' creato/aperto, fd=%d\n", filename, fd);
// ===========================================
// 2. Estendi file alla dimensione desiderata
// ===========================================
// ftruncate() imposta la dimensione del file
// È NECESSARIO prima di mappare: non si può mappare file vuoto!
if (ftruncate(fd, data_len) == -1) {
perror("ftruncate");
close(fd);
exit(EXIT_FAILURE);
}
printf("File esteso a %zu bytes\n", data_len);
// ===========================================
// 3. Mappa file in memoria
// ===========================================
// mmap() crea una mappatura tra memoria virtuale e file
// Parametri:
// NULL: lascia kernel scegliere indirizzo
// data_len: dimensione mappatura
// PROT_READ|PROT_WRITE: permessi read+write
// MAP_SHARED: modifiche visibili ad altri processi e salvate su file
// fd: file descriptor da mappare
// 0: offset nel file (0 = dall'inizio)
char *mapped = mmap(NULL, data_len, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
perror("mmap");
close(fd);
exit(EXIT_FAILURE);
}
printf("File mappato in memoria all'indirizzo %p\n", (void*)mapped);
// IMPORTANTE: Dopo mmap(), possiamo chiudere fd
// La mappatura resta valida!
close(fd);
printf("File descriptor chiuso (mappatura ancora valida)\n");
// ===========================================
// 4. Scrivi direttamente nella memoria mappata
// ===========================================
// Scrivere in 'mapped' è come scrivere nel file!
// Non serve write() - usiamo memcpy o strcpy
memcpy(mapped, data, data_len);
printf("Dati scritti in memoria mappata\n");
// A questo punto i dati sono nel page cache del kernel
// ma potrebbero non essere ancora sul disco fisico
// ===========================================
// 5. Sincronizza con il disco
// ===========================================
// msync() forza scrittura su disco
// Parametri:
// mapped: indirizzo mappatura
// data_len: lunghezza
// MS_SYNC: sincrono (blocca fino a completamento)
// Altre opzioni: MS_ASYNC (asincrono), MS_INVALIDATE
if (msync(mapped, data_len, MS_SYNC) == -1) {
perror("msync");
// Non fatale, ma dati potrebbero perdersi
}
printf("Dati sincronizzati su disco\n");
// ===========================================
// 6. Leggi dalla memoria mappata
// ===========================================
// Leggere da 'mapped' è come leggere dal file!
printf("Contenuto memoria mappata: %.*s\n", (int)data_len, mapped);
// ===========================================
// 7. Modifica in-place
// ===========================================
// Possiamo modificare byte specifici direttamente
mapped[0] = 'c'; // 'C' -> 'c'
mapped[10] = 'M'; // Cambia un byte in mezzo
printf("Contenuto modificato: %.*s\n", (int)data_len, mapped);
// Sincronizza modifiche
msync(mapped, data_len, MS_SYNC);
// ===========================================
// 8. Rilascia mapping
// ===========================================
// munmap() rilascia la mappatura
// Dopo munmap(), l'indirizzo 'mapped' non è più valido
if (munmap(mapped, data_len) == -1) {
perror("munmap");
exit(EXIT_FAILURE);
}
printf("Mapping rilasciato\n");
// ===========================================
// 9. Verifica che le modifiche persistono
// ===========================================
// Riapriamo il file normalmente e leggiamo
fd = open(filename, O_RDONLY);
if (fd != -1) {
char verify_buf[100];
ssize_t n = read(fd, verify_buf, sizeof(verify_buf) - 1);
if (n > 0) {
verify_buf[n] = '\0';
printf("Verifica da file: %s\n", verify_buf);
}
close(fd);
}
printf("\n=== Vantaggi di mmap ===\n");
printf("1. Accesso casuale efficiente (come array)\n");
printf("2. Zero-copy: no buffer intermedi\n");
printf("3. Shared memory: più processi vedono stessi dati\n");
printf("4. File grandi: mappa solo porzione necessaria\n");
printf("5. Lazy loading: pagine caricate on-demand\n");
return 0;
}
Output del programma:
File 'mapped_file.txt' creato/aperto, fd=3
File esteso a 30 bytes
File mappato in memoria all'indirizzo 0x7f1234567000
File descriptor chiuso (mappatura ancora valida)
Dati scritti in memoria mappata
Dati sincronizzati su disco
Contenuto memoria mappata: Contenuto mappato in memoria!
Contenuto modificato: contenuto Mappato in memoria!
Mapping rilasciato
Verifica da file: contenuto Mappato in memoria!
=== Vantaggi di mmap ===
1. Accesso casuale efficiente (come array)
2. Zero-copy: no buffer intermedi
3. Shared memory: più processi vedono stessi dati
4. File grandi: mappa solo porzione necessaria
5. Lazy loading: pagine caricate on-demand
Quando usare mmap vs read/write:
| Scenario | mmap | read/write |
|---|---|---|
| File piccoli (<100KB) | ❌ Overhead | ✅ Più semplice |
| File grandi (>10MB) | ✅ Efficiente | ❌ Lento |
| Accesso sequenziale | ⚠️ Ok | ✅ Ottimale |
| Accesso casuale | ✅ Molto efficiente | ❌ Molti lseek |
| Shared memory | ✅ Ideale (MAP_SHARED) | ❌ Non supportato |
| Semplicità codice | ⚠️ Più complesso | ✅ Più semplice |
| Portabilità | ⚠️ POSIX-only | ✅ Universale |
System call per la comunicazione tra processi.
Pipe e FIFO:
// Pipe
int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);
// FIFO (Named Pipe)
int mkfifo(const char *pathname, mode_t mode);
Esempio completo di pipe con spiegazione dettagliata:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main() {
// Array per contenere i due file descriptor della pipe
int pipefd[2];
pid_t pid;
char buffer[100];
// ===========================================
// 1. Crea la pipe
// ===========================================
// pipe() crea una coppia di file descriptor collegati:
// pipefd[0]: lato lettura (read end)
// pipefd[1]: lato scrittura (write end)
// I dati scritti in pipefd[1] possono essere letti da pipefd[0]
if (pipe(pipefd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
printf("Pipe creata: fd[0]=%d (read), fd[1]=%d (write)\n",
pipefd[0], pipefd[1]);
// ===========================================
// 2. Fork per creare processo figlio
// ===========================================
// Dopo fork(), entrambi padre e figlio hanno
// COPIA dei due file descriptor della pipe
pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// ===== PROCESSO FIGLIO =====
printf("[FIGLIO %d] Iniziato\n", getpid());
// Il figlio leggerà dalla pipe
// Quindi chiudiamo il lato scrittura (non lo useremo)
// Questo è IMPORTANTE: ogni end deve essere chiuso da chi non lo usa
close(pipefd[1]);
printf("[FIGLIO] Chiuso lato scrittura (fd=%d)\n", pipefd[1]);
// Leggiamo dalla pipe (pipefd[0])
// read() si blocca fino a che ci sono dati disponibili
printf("[FIGLIO] In attesa di dati dalla pipe...\n");
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (n == -1) {
perror("[FIGLIO] read");
exit(EXIT_FAILURE);
} else if (n == 0) {
// n == 0 significa EOF
// Questo succede quando tutti i processi chiudono il write end
printf("[FIGLIO] EOF ricevuto (pipe chiusa)\n");
} else {
// Dati ricevuti!
buffer[n] = '\0'; // Null-terminate per printf
printf("[FIGLIO] Ricevuti %zd bytes: '%s'\n", n, buffer);
}
// Chiudi lato lettura (abbiamo finito)
close(pipefd[0]);
printf("[FIGLIO] Chiuso lato lettura, termino\n");
exit(EXIT_SUCCESS);
} else {
// ===== PROCESSO PADRE =====
printf("[PADRE %d] Figlio creato con PID=%d\n", getpid(), pid);
// Il padre scriverà nella pipe
// Quindi chiudiamo il lato lettura (non lo useremo)
close(pipefd[0]);
printf("[PADRE] Chiuso lato lettura (fd=%d)\n", pipefd[0]);
// Prepariamo il messaggio da inviare
const char *msg = "Messaggio dal padre via pipe!";
printf("[PADRE] Invio messaggio: '%s'\n", msg);
// Scriviamo nella pipe (pipefd[1])
// write() scrive i byte nel buffer della pipe
ssize_t written = write(pipefd[1], msg, strlen(msg));
if (written == -1) {
perror("[PADRE] write");
exit(EXIT_FAILURE);
}
printf("[PADRE] Scritti %zd bytes nella pipe\n", written);
// IMPORTANTE: Chiudiamo il write end
// Questo causa EOF nel figlio quando legge tutto
// Se non chiudiamo, il figlio rimarrebbe bloccato in read()
close(pipefd[1]);
printf("[PADRE] Chiuso lato scrittura\n");
// Aspettiamo che il figlio termini
int status;
pid_t terminated = wait(&status);
if (terminated == -1) {
perror("[PADRE] wait");
} else {
printf("[PADRE] Figlio %d terminato", terminated);
if (WIFEXITED(status)) {
printf(" con exit code %d\n", WEXITSTATUS(status));
} else {
printf(" anormalmente\n");
}
}
printf("[PADRE] Termino\n");
}
return 0;
}
Output tipico del programma:
Pipe creata: fd[0]=3 (read), fd[1]=4 (write)
[PADRE 1234] Figlio creato con PID=1235
[PADRE] Chiuso lato lettura (fd=3)
[PADRE] Invio messaggio: 'Messaggio dal padre via pipe!'
[FIGLIO 1235] Iniziato
[FIGLIO] Chiuso lato scrittura (fd=4)
[FIGLIO] In attesa di dati dalla pipe...
[PADRE] Scritti 30 bytes nella pipe
[PADRE] Chiuso lato scrittura
[FIGLIO] Ricevuti 30 bytes: 'Messaggio dal padre via pipe!'
[FIGLIO] Chiuso lato lettura, termino
[PADRE] Figlio 1235 terminato con exit code 0
[PADRE] Termino
Spiegazione dettagliata del meccanismo pipe:
Prima di pipe(): nessun fd speciale
Dopo pipe():
Padre: fd[0]=3 (read) ←─┐
fd[1]=4 (write)─┐ │
│ │ PIPE KERNEL
Figlio: (non esiste) │ │ (buffer interno)
↓ ↑
Padre: fd[0]=3 (read) ←─┐
fd[1]=4 (write)─┐ │
│ │ PIPE KERNEL
Figlio: fd[0]=3 (read) ←┘ │ (STESSO buffer
fd[1]=4 (write)───┘ condiviso!)
Padre: fd[0]=CHIUSO
fd[1]=4 (write)────┐
│ PIPE KERNEL
Figlio: fd[0]=3 (read) ←───┤
fd[1]=CHIUSO │
Comunicazione unidirezionale: Padre → Figlio
Perché chiudere i file descriptor non usati?
EOF corretto: Se padre non chiude write end dopo aver scritto,
figlio non riceverà mai EOF e rimarrà bloccato in read()
Resource leak: File descriptor non chiusi consumano risorse
Segnali corretti: Se tutti i write end sono chiusi e si tenta write(),
si riceve SIGPIPE (desiderato per rilevare pipe rotte)
Pipe bidirezionali (necessitano 2 pipe):
int pipe1[2], pipe2[2]; // Due pipe
pipe(pipe1); // Padre → Figlio
pipe(pipe2); // Figlio → Padre
if (fork() == 0) {
// Figlio
close(pipe1[1]); // Chiude write end di pipe1
close(pipe2[0]); // Chiude read end di pipe2
// Legge da pipe1, scrive in pipe2
read(pipe1[0], ...);
write(pipe2[1], ...);
} else {
// Padre
close(pipe1[0]); // Chiude read end di pipe1
close(pipe2[1]); // Chiude write end di pipe2
// Scrive in pipe1, legge da pipe2
write(pipe1[1], ...);
read(pipe2[0], ...);
}
Limitazioni delle pipe:
Vantaggi delle pipe:
PIPE_BUF:
#include <limits.h>
printf("PIPE_BUF = %d bytes\n", PIPE_BUF);
// Tipicamente 4096 su Linux
// Write di dimensione ≤ PIPE_BUF sono atomiche
Message Queue, Semafori, Shared Memory (System V IPC):
// Message Queue
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
int msgflg);
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
// Semafori
int semget(key_t key, int nsems, int semflg);
int semop(int semid, struct sembuf *sops, size_t nsops);
int semctl(int semid, int semnum, int cmd, ...);
// Shared Memory (già visto sopra)
Socket:
// Creazione socket
int socket(int domain, int type, int protocol);
// Bind e listen (server)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// Connect (client)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// Invio e ricezione
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
I segnali sono il meccanismo Unix per gestire eventi asincroni: interruzioni, errori, timer, o comunicazione tra processi. Sono simili agli interrupt hardware, ma a livello software.
System call per gestire segnali:
// Handler segnali
sighandler_t signal(int signum, sighandler_t handler);
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
// Invio segnali
int kill(pid_t pid, int sig);
int raise(int sig);
int sigqueue(pid_t pid, int sig, const union sigval value);
// Signal mask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigsuspend(const sigset_t *mask);
// Attesa segnali
int pause(void);
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info,
const struct timespec *timeout);
Spiegazione delle system call:
signal(signum, handler)
signum: numero del segnale (es. SIGINT = 2)handler: può essere:
void handler(int signum)SIG_DFL: comportamento defaultSIG_IGN: ignora il segnalesigaction() che è più portabile e sicurasigaction(signum, act, oldact)
act: nuova azione da installareoldact: riceve la vecchia azione (può essere NULL)sigaction contiene:
sa_handler o sa_sigaction: handler functionsa_mask: segnali da bloccare durante handlersa_flags: flag di controllo (SA_RESTART, SA_SIGINFO, etc.)kill(pid, sig)
pid > 0: invia a processo specificopid == 0: invia a tutti i processi nel gruppo del chiamantepid == -1: invia a tutti i processi (tranne init)pid < -1: invia al gruppo di processi |pid|raise(sig)
kill(getpid(), sig)sigprocmask(how, set, oldset)
how:
SIG_BLOCK: aggiungi set alla maschera correnteSIG_UNBLOCK: rimuovi set dalla mascheraSIG_SETMASK: imposta maschera a setpause()
Segnali comuni:
| Segnale | Numero | Significato | Default |
|---|---|---|---|
| SIGHUP | 1 | Hangup (terminale chiuso) | Termina |
| SIGINT | 2 | Interrupt (Ctrl+C) | Termina |
| SIGQUIT | 3 | Quit (Ctrl+\) | Termina + core |
| SIGILL | 4 | Istruzione illegale | Termina + core |
| SIGABRT | 6 | Abort | Termina + core |
| SIGFPE | 8 | Floating point exception | Termina + core |
| SIGKILL | 9 | Kill (non catturabile) | Termina |
| SIGSEGV | 11 | Segmentation fault | Termina + core |
| SIGPIPE | 13 | Pipe rotta | Termina |
| SIGALRM | 14 | Alarm timer | Termina |
| SIGTERM | 15 | Terminazione (default kill) | Termina |
| SIGCHLD | 17 | Figlio terminato | Ignora |
| SIGCONT | 18 | Continua | Continua |
| SIGSTOP | 19 | Stop (non catturabile) | Stop |
| SIGTSTP | 20 | Stop da terminale (Ctrl+Z) | Stop |
Esempio completo di gestione segnali:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
// Variabile globale modificata dall'handler
// sig_atomic_t garantisce accesso atomico
volatile sig_atomic_t signal_received = 0;
// Handler per SIGINT (Ctrl+C)
void sigint_handler(int signum) {
// IMPORTANTE: Gli handler devono essere "async-signal-safe"
// Non tutte le funzioni sono sicure dentro un handler!
// Safe: modificare variabili sig_atomic_t
signal_received = 1;
// write() è async-signal-safe, printf() NO!
// Usiamo write() invece di printf()
const char msg[] = "\nSIGINT ricevuto!\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
// NOTA: L'handler ritorna automaticamente,
// e l'esecuzione riprende dal punto in cui era stata interrotta
// Per SIGINT, il comportamento default è terminare il processo
// Ma siccome abbiamo installato il nostro handler,
// il processo continua l'esecuzione!
}
// Handler per SIGTERM
void sigterm_handler(int signum) {
const char msg[] = "SIGTERM ricevuto, termino...\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
// Termina il processo
// Possiamo fare cleanup qui se necessario
exit(EXIT_SUCCESS);
}
// Handler per SIGALRM (timer)
void sigalrm_handler(int signum) {
const char msg[] = "SIGALRM: timer scaduto!\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main() {
// Struttura per configurare l'azione del segnale
struct sigaction sa_int, sa_term, sa_alrm;
// ===========================================
// Configura handler per SIGINT (Ctrl+C)
// ===========================================
// Inizializza la struttura
// sa_handler: puntatore alla funzione handler
sa_int.sa_handler = sigint_handler;
// Inizializza set di segnali vuoto
// Questi segnali saranno bloccati durante l'esecuzione dell'handler
sigemptyset(&sa_int.sa_mask);
// Flag: 0 significa comportamento default
// Altri flag comuni:
// SA_RESTART: riavvia automaticamente syscall interrotte
// SA_SIGINFO: usa sa_sigaction invece di sa_handler
// SA_NODEFER: non bloccare il segnale durante l'handler
sa_int.sa_flags = 0;
// Installa l'handler
// NULL come terzo parametro = non ci interessa la vecchia azione
if (sigaction(SIGINT, &sa_int, NULL) == -1) {
perror("sigaction SIGINT");
exit(EXIT_FAILURE);
}
printf("Handler SIGINT installato\n");
// ===========================================
// Configura handler per SIGTERM
// ===========================================
sa_term.sa_handler = sigterm_handler;
sigemptyset(&sa_term.sa_mask);
sa_term.sa_flags = 0;
if (sigaction(SIGTERM, &sa_term, NULL) == -1) {
perror("sigaction SIGTERM");
exit(EXIT_FAILURE);
}
printf("Handler SIGTERM installato\n");
// ===========================================
// Configura handler per SIGALRM
// ===========================================
sa_alrm.sa_handler = sigalrm_handler;
sigemptyset(&sa_alrm.sa_mask);
sa_alrm.sa_flags = 0;
if (sigaction(SIGALRM, &sa_alrm, NULL) == -1) {
perror("sigaction SIGALRM");
exit(EXIT_FAILURE);
}
printf("Handler SIGALRM installato\n");
// ===========================================
// Imposta un alarm
// ===========================================
// alarm() invia SIGALRM dopo N secondi
// Ritorna il numero di secondi rimanenti del precedente alarm (0 se nessuno)
unsigned int prev_alarm = alarm(5);
printf("Alarm impostato a 5 secondi (precedente: %u)\n", prev_alarm);
// ===========================================
// Informazioni per l'utente
// ===========================================
printf("\n=== Comandi disponibili ===\n");
printf("PID del processo: %d\n", getpid());
printf("Premi Ctrl+C per inviare SIGINT\n");
printf("Da altro terminale, usa: kill %d (SIGTERM)\n", getpid());
printf(" o: kill -SIGINT %d\n", getpid());
printf("Alarm SIGALRM scatterà tra 5 secondi\n");
printf("===========================\n\n");
// ===========================================
// Loop infinito (il programma resta in esecuzione)
// ===========================================
int count = 0;
while (1) {
// Controlla se abbiamo ricevuto SIGINT
if (signal_received) {
printf("Main loop: SIGINT gestito, continuo l'esecuzione...\n");
// Reset del flag
signal_received = 0;
// Potremmo decidere di uscire dopo N SIGINT
count++;
if (count >= 3) {
printf("Ricevuti 3 SIGINT, esco.\n");
break;
}
}
// Stampa un messaggio periodico per mostrare che siamo vivi
printf(".");
fflush(stdout); // Forza stampa immediata
// Dormi per 1 secondo
// sleep() può essere interrotta da segnali
unsigned int remaining = sleep(1);
if (remaining > 0) {
// sleep interrotta (probabilmente da segnale)
// remaining contiene i secondi non dormiti
// (non stampiamo nulla, continuiamo il loop)
}
}
printf("\nProgramma terminato normalmente\n");
return 0;
}
Compilazione ed esecuzione:
gcc -o signals signals.c
./signals
Output esempio:
Handler SIGINT installato
Handler SIGTERM installato
Handler SIGALRM installato
Alarm impostato a 5 secondi (precedente: 0)
=== Comandi disponibili ===
PID del processo: 12345
Premi Ctrl+C per inviare SIGINT
Da altro terminale, usa: kill 12345 (SIGTERM)
o: kill -SIGINT 12345
Alarm SIGALRM scatterà tra 5 secondi
===========================
....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
.....SIGALRM: timer scaduto!
....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
.....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
Ricevuti 3 SIGINT, esco.
Programma terminato normalmente
Spiegazione dettagliata:
volatile sig_atomic_t signal_received:
volatile: dice al compilatore di non ottimizzare accessi a questa variabilesig_atomic_t: tipo garantito atomico, sicuro da modificare in handlerHandler async-signal-safe:
man 7 signal)write() è sicura, printf() NOprintf() mentre il main era già in printf(), potremmo corrompere strutture internesigemptyset(&sa_int.sa_mask):
sa_mask specifica segnali da bloccare durante l’handlersa_flags = 0:
SA_RESTART: system call interrotte vengono automaticamente riavviateread(), write(), etc. interrotte ritornano EINTRalarm(5):
alarm(0) cancella l’alarm attivoBlocco e sblocco segnali:
// Esempio di blocco temporaneo di segnali
sigset_t set, oldset;
// Inizializza set vuoto
sigemptyset(&set);
// Aggiungi SIGINT al set
sigaddset(&set, SIGINT);
// Blocca SIGINT (viene aggiunto alla maschera corrente)
sigprocmask(SIG_BLOCK, &set, &oldset);
// Sezione critica - SIGINT non verrà consegnato qui
// (resta pending se arriva)
printf("SIGINT bloccato in questa sezione\n");
sleep(5);
// Ripristina maschera precedente (sblocca SIGINT)
sigprocmask(SIG_SETMASK, &oldset, NULL);
// Se SIGINT era pending, viene consegnato ORA
Casi d’uso reali dei segnali:
System call per operazioni temporali.
// Tempo corrente
time_t time(time_t *tloc);
int gettimeofday(struct timeval *tv, struct timezone *tz);
int clock_gettime(clockid_t clockid, struct timespec *tp);
// Impostazione tempo (richiede privilegi)
int settimeofday(const struct timeval *tv, const struct timezone *tz);
int clock_settime(clockid_t clockid, const struct timespec *tp);
// Sleep
unsigned int sleep(unsigned int seconds);
int nanosleep(const struct timespec *req, struct timespec *rem);
int clock_nanosleep(clockid_t clockid, int flags,
const struct timespec *request,
struct timespec *remain);
// Timer
int timer_create(clockid_t clockid, struct sigevent *sevp,
timer_t *timerid);
int timer_settime(timer_t timerid, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
int timer_gettime(timer_t timerid, struct itimerspec *curr_value);
int timer_delete(timer_t timerid);
// Allarmi
unsigned int alarm(unsigned int seconds);
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);
Esempio di misurazione tempo:
#include <stdio.h>
#include <time.h>
#include <unistd.h>
void operazione_costosa() {
// Simula operazione che richiede tempo
usleep(500000); // 500ms
}
int main() {
struct timespec start, end;
// Tempo di sistema (wall-clock time)
clock_gettime(CLOCK_REALTIME, &start);
printf("Inizio: %ld.%09ld\n", start.tv_sec, start.tv_nsec);
operazione_costosa();
clock_gettime(CLOCK_REALTIME, &end);
printf("Fine: %ld.%09ld\n", end.tv_sec, end.tv_nsec);
// Calcola tempo trascorso
long sec_diff = end.tv_sec - start.tv_sec;
long nsec_diff = end.tv_nsec - start.tv_nsec;
if (nsec_diff < 0) {
sec_diff--;
nsec_diff += 1000000000L;
}
printf("Tempo trascorso: %ld.%09ld secondi\n", sec_diff, nsec_diff);
// CPU time del processo
struct timespec cpu_time;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_time);
printf("CPU time del processo: %ld.%09ld secondi\n",
cpu_time.tv_sec, cpu_time.tv_nsec);
return 0;
}
System call per monitorare multipli file descriptor.
// select
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
// poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// epoll (Linux-specific, più efficiente)
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
Esempio con select:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
int main() {
fd_set readfds;
struct timeval tv;
int retval;
char buffer[100];
printf("Aspetto input da stdin (timeout 5 secondi)...\n");
// Inizializza set di file descriptor
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
// Imposta timeout
tv.tv_sec = 5;
tv.tv_usec = 0;
// Monitora stdin
retval = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
if (retval == -1) {
perror("select");
exit(EXIT_FAILURE);
} else if (retval) {
printf("Dati disponibili su stdin\n");
if (FD_ISSET(STDIN_FILENO, &readfds)) {
ssize_t n = read(STDIN_FILENO, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("Letto: %s", buffer);
}
}
} else {
printf("Timeout! Nessun dato ricevuto in 5 secondi.\n");
}
return 0;
}
Informazioni e controllo del sistema.
// Informazioni sistema
int uname(struct utsname *buf);
int sysinfo(struct sysinfo *info);
// Reboot (richiede privilegi root)
int reboot(int magic, int magic2, int cmd, void *arg);
// Montaggio filesystem (richiede privilegi)
int mount(const char *source, const char *target,
const char *filesystemtype, unsigned long mountflags,
const void *data);
int umount(const char *target);
int umount2(const char *target, int flags);
// Controllo risorse
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);
// Priorità processi
int nice(int inc);
int getpriority(int which, id_t who);
int setpriority(int which, id_t who, int prio);
// Hostname
int gethostname(char *name, size_t len);
int sethostname(const char *name, size_t len);
Esempio informazioni sistema:
#include <stdio.h>
#include <stdlib.h>
#include <sys/utsname.h>
#include <sys/sysinfo.h>
#include <sys/resource.h>
#include <unistd.h>
int main() {
struct utsname uts;
struct sysinfo si;
struct rlimit rlim;
// Informazioni sul sistema operativo
if (uname(&uts) == 0) {
printf("=== Informazioni Sistema ===\n");
printf("Sistema operativo: %s\n", uts.sysname);
printf("Nome nodo: %s\n", uts.nodename);
printf("Release: %s\n", uts.release);
printf("Versione: %s\n", uts.version);
printf("Architettura: %s\n\n", uts.machine);
}
// Statistiche sistema
if (sysinfo(&si) == 0) {
printf("=== Statistiche Sistema ===\n");
printf("Uptime: %ld secondi\n", si.uptime);
printf("RAM totale: %lu MB\n", si.totalram / (1024 * 1024));
printf("RAM libera: %lu MB\n", si.freeram / (1024 * 1024));
printf("Numero processi: %d\n\n", si.procs);
}
// Limiti risorse processo
if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
printf("=== Limiti Risorse ===\n");
printf("Max file descriptor aperti:\n");
printf(" Soft limit: %ld\n", (long)rlim.rlim_cur);
printf(" Hard limit: %ld\n\n", (long)rlim.rlim_max);
}
// Priorità processo
int prio = getpriority(PRIO_PROCESS, 0);
printf("Priorità corrente (nice): %d\n", prio);
// Hostname
char hostname[256];
if (gethostname(hostname, sizeof(hostname)) == 0) {
printf("Hostname: %s\n", hostname);
}
return 0;
}
La gestione corretta degli errori è fondamentale nella programmazione di sistema. Le system call, a differenza delle funzioni normali, possono fallire per svariati motivi e l’applicazione deve essere preparata a gestire ogni situazione.
La variabile globale errno è il meccanismo principale per comunicare errori dalle system call alle applicazioni. Ecco come funziona:
Comportamento delle system call in caso di errore:
errno a un codice di errore specifico#include <errno.h> // per errno
#include <string.h> // per strerror()
#include <stdio.h> // per perror()
#include <fcntl.h> // per open()
#include <unistd.h> // per close()
int main() {
// Tentiamo di aprire un file che probabilmente non esiste
int fd = open("/file_inesistente", O_RDONLY);
// Controlliamo il valore di ritorno
if (fd == -1) {
// open() ha fallito, errno è stato impostato
// METODO 1: Stampare il numero di errno
printf("Errore numero: %d\n", errno);
// Output: "Errore numero: 2"
// METODO 2: Convertire errno in stringa descrittiva
printf("Descrizione: %s\n", strerror(errno));
// Output: "Descrizione: No such file or directory"
// METODO 3: Usare perror() - più semplice
perror("open");
// Output: "open: No such file or directory"
// perror() è equivalente a:
// fprintf(stderr, "open: %s\n", strerror(errno));
return 1; // Exit con errore
}
// Se arriviamo qui, fd è valido (>= 0)
printf("File aperto con successo, fd = %d\n", fd);
close(fd);
return 0;
}
Dettagli importanti su errno:
errno = 0; // Reset manuale
int fd = open("file_esistente", O_RDONLY); // Successo
// errno potrebbe ancora contenere vecchio valore!
// CORRETTO: Controlla sempre il valore di ritorno PRIMA di guardare errno
if (fd == -1) {
// SOLO ora errno è affidabile
if (errno == ENOENT) { /* ... */ }
}
int fd = open("file", O_RDONLY);
if (fd == -1) {
int saved_errno = errno; // Salva subito!
printf("Debug info...\n"); // printf potrebbe modificare errno!
// Usa saved_errno invece di errno
fprintf(stderr, "Errore: %s\n", strerror(saved_errno));
}
// errno è in realtà una macro che espande a:
#define errno (*__errno_location())
// Ogni thread ha la sua copia di errno
// Thread A può impostare errno senza interferire con Thread B
La header <errno.h> definisce decine di codici di errore. Ecco i più comuni:
#include <errno.h>
// ===== ERRORI GENERALI =====
EPERM 1 // Operation not permitted
// Operazione richiede privilegi che il processo non ha
// Esempio: tentare di cambiare owner di un file non proprio
ENOENT 2 // No such file or directory
// File o directory specificata non esiste
// Esempio: open("/file/inesistente", ...)
ESRCH 3 // No such process
// PID specificato non esiste
// Esempio: kill(99999, SIGTERM) dove 99999 non è un PID valido
EINTR 4 // Interrupted system call
// System call interrotta da un segnale
// Molte syscall possono essere interrotte (read, write, wait, etc.)
// Spesso si deve ritentare
EIO 5 // I/O error
// Errore fisico di I/O (disco corrotto, network down, etc.)
// Grave - i dati potrebbero essere persi
ENXIO 6 // No such device or address
// Device specificato non esiste
// Esempio: aprire /dev/dispositivo_inesistente
E2BIG 7 // Argument list too long
// Troppi argomenti passati a exec()
// Lista argomenti > ARG_MAX (tipicamente 128KB)
EBADF 9 // Bad file descriptor
// File descriptor non valido (non aperto o già chiuso)
// Bug comune: usare fd dopo close()
EAGAIN 11 // Try again / Resource temporarily unavailable
// Risorsa temporaneamente non disponibile
// Non è un errore fatale - ritentare
// Esempio: socket non-blocking senza dati disponibili
// NOTA: EWOULDBLOCK è spesso uguale a EAGAIN
ENOMEM 12 // Out of memory
// Memoria insufficiente per completare l'operazione
// Sistema sotto stress di memoria
EACCES 13 // Permission denied
// Permessi insufficienti
// Esempio: tentare di aprire /etc/shadow senza essere root
EFAULT 14 // Bad address
// Puntatore passato alla syscall non valido
// Esempio: read(fd, NULL, 100) - NULL non è un indirizzo valido
ENOTBLK 15 // Block device required
// Operazione richiede block device, ma è stato dato altro
EBUSY 16 // Device or resource busy
// Risorsa in uso, non può essere acceduta ora
// Esempio: tentare di unmount filesystem in uso
EEXIST 17 // File exists
// File già esiste quando dovrebbe essere nuovo
// Esempio: open("file", O_CREAT | O_EXCL) su file esistente
EXDEV 18 // Cross-device link
// Tentativo di creare hard link attraverso filesystem diversi
ENODEV 19 // No such device
// Device specificato non esiste
ENOTDIR 20 // Not a directory
// Componente del path è un file, non una directory
// Esempio: open("/etc/passwd/subdir/file", ...) - passwd è un file!
EISDIR 21 // Is a directory
// Operazione valida solo su file, ma è stata data directory
// Esempio: open("/etc", O_WRONLY) - non si può aprire directory in scrittura
EINVAL 22 // Invalid argument
// Argomento non valido
// Esempio: lseek(fd, 0, 999) - 999 non è un whence valido
ENFILE 23 // File table overflow
// Troppi file aperti nel sistema (limite globale)
EMFILE 24 // Too many open files
// Troppi file aperti in questo processo (limite per-processo)
// Controllare con: ulimit -n
ENOTTY 25 // Not a typewriter (Not a terminal)
// Operazione ioctl non valida per questo tipo di dispositivo
ETXTBSY 26 // Text file busy
// File eseguibile aperto per scrittura
// Non si può eseguire un file mentre è aperto in scrittura
EFBIG 27 // File too large
// File supera limite massimo
// Controllare con: ulimit -f
ENOSPC 28 // No space left on device
// Filesystem pieno
// Errore comune - disco pieno!
ESPIPE 29 // Illegal seek
// lseek() su pipe o socket (non supportato)
EROFS 30 // Read-only file system
// Tentativo di modifica su filesystem read-only
EMLINK 31 // Too many links
// Troppi hard link al file
EPIPE 32 // Broken pipe
// Scrittura su pipe senza lettore
// Genera anche SIGPIPE per default
ERANGE 34 // Math result not representable
// Risultato fuori range
// ===== ERRORI NETWORK =====
EADDRINUSE 98 // Address already in use
// Tentativo di bind() su indirizzo già in uso
// Esempio: due server sulla stessa porta
ECONNREFUSED 111 // Connection refused
// Server non accetta connessioni
// Esempio: connect() a porta dove nessuno ascolta
ETIMEDOUT 110 // Connection timed out
// Operazione network scaduta (timeout)
// ===== ERRORI DEADLOCK =====
EDEADLK 35 // Resource deadlock would occur
// Operazione causerebbe deadlock
// Sistema ha rilevato potenziale deadlock e lo previene
// ===== ERRORI NOME FILE =====
ENAMETOOLONG 36 // File name too long
// Nome file o path troppo lungo
// PATH_MAX tipicamente 4096, NAME_MAX tipicamente 255
Come interpretare errno:
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
void open_with_error_analysis(const char *path) {
int fd = open(path, O_RDONLY);
if (fd == -1) {
// Analisi dettagliata dell'errore
switch (errno) {
case ENOENT:
fprintf(stderr, "File '%s' non esiste\n", path);
fprintf(stderr, "Suggerimento: Verifica il path\n");
break;
case EACCES:
fprintf(stderr, "Permesso negato per '%s'\n", path);
fprintf(stderr, "Suggerimento: Controlla permessi con ls -l\n");
break;
case EMFILE:
fprintf(stderr, "Troppi file aperti in questo processo\n");
fprintf(stderr, "Suggerimento: Chiudi file non più usati\n");
break;
case ENFILE:
fprintf(stderr, "Troppi file aperti nel sistema\n");
fprintf(stderr, "Suggerimento: Problema di sistema, contatta admin\n");
break;
case EISDIR:
fprintf(stderr, "'%s' è una directory, non un file\n", path);
break;
case ENAMETOOLONG:
fprintf(stderr, "Path troppo lungo: %s\n", path);
break;
default:
// Per errori non gestiti, usa strerror
fprintf(stderr, "Errore sconosciuto: %s\n", strerror(errno));
break;
}
} else {
printf("File aperto con successo: fd=%d\n", fd);
close(fd);
}
}
Pattern 1: Controllo Semplice - Exit on Error
Adatto per programmi semplici dove qualsiasi errore è fatale:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// Apre file - se fallisce, termina immediatamente
int fd = open("data.txt", O_RDONLY);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE); // EXIT_FAILURE = 1
}
// Legge dati - se fallisce, termina
char buffer[100];
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n == -1) {
perror("read");
close(fd); // Cleanup prima di uscire
exit(EXIT_FAILURE);
}
close(fd);
return 0;
}
Pattern 2: Gestione Specifica per Errore
Gestione differenziata basata sul tipo di errore:
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
int main() {
const char *filename = "important.txt";
int fd = open(filename, O_RDONLY);
if (fd == -1) {
// Gestiamo errori specifici in modo diverso
if (errno == ENOENT) {
// File non esiste - potremmo crearlo
fprintf(stderr, "File '%s' non trovato\n", filename);
fprintf(stderr, "Vuoi crearlo? ...\n");
// ...logica per creare file...
} else if (errno == EACCES) {
// Permessi insufficienti - problema più serio
fprintf(stderr, "Permesso negato per '%s'\n", filename);
fprintf(stderr, "Esegui con privilegi maggiori o cambia permessi\n");
exit(EXIT_FAILURE);
} else if (errno == EMFILE) {
// Troppi file aperti - problema di risorse
fprintf(stderr, "Troppi file aperti\n");
// Potremmo cercare di chiudere file non necessari
// ...cleanup...
// ...riprova...
} else {
// Errore inaspettato
perror("open");
exit(EXIT_FAILURE);
}
}
// Continua se fd è valido...
if (fd != -1) {
// ... usa fd ...
close(fd);
}
return 0;
}
Pattern 3: Retry su EINTR (Fondamentale!)
Molte system call possono essere interrotte da segnali. Dobbiamo gestire EINTR:
#include <errno.h>
#include <unistd.h>
// Wrapper per read che gestisce EINTR automaticamente
ssize_t safe_read(int fd, void *buf, size_t count) {
ssize_t n;
// Loop fino a successo o errore diverso da EINTR
do {
n = read(fd, buf, count);
// Se n == -1 E errno == EINTR, riprova
// Altrimenti (successo o altro errore), ritorna
} while (n == -1 && errno == EINTR);
return n;
}
// Stesso pattern per write
ssize_t safe_write(int fd, const void *buf, size_t count) {
ssize_t n;
do {
n = write(fd, buf, count);
} while (n == -1 && errno == EINTR);
return n;
}
// E per altre syscall bloccanti...
pid_t safe_wait(int *status) {
pid_t pid;
do {
pid = wait(status);
} while (pid == -1 && errno == EINTR);
return pid;
}
Perché EINTR è speciale?
// Scenario senza gestione EINTR:
signal(SIGALRM, handler);
alarm(5); // Alarm tra 5 secondi
char buf[1000];
ssize_t n = read(fd, buf, sizeof(buf)); // Potrebbe bloccare per minuti
if (n == -1) {
perror("read"); // Stampa "read: Interrupted system call"
// Ma non è un errore reale! Dovremmo solo riprovare
}
Pattern 4: Cleanup su Errore (goto cleanup)
Pattern molto comune per gestire multiple risorse:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
int process_file(const char *filename) {
int fd = -1;
void *buffer = NULL;
int result = -1; // Assume fallimento
// ===========================================
// Tentativo apertura file
// ===========================================
fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open");
goto cleanup; // Salta al cleanup
}
// ===========================================
// Tentativo allocazione buffer
// ===========================================
buffer = malloc(4096);
if (buffer == NULL) {
fprintf(stderr, "malloc fallita\n");
goto cleanup; // fd deve essere chiuso!
}
// ===========================================
// Tentativo lettura
// ===========================================
ssize_t n = read(fd, buffer, 4096);
if (n == -1) {
perror("read");
goto cleanup; // fd E buffer devono essere rilasciati!
}
// ===========================================
// Successo! Elabora dati
// ===========================================
printf("Letti %zd bytes\n", n);
// ... elaborazione ...
result = 0; // Successo
// ===========================================
// Cleanup: Eseguito sempre, successo o errore
// ===========================================
cleanup:
// Rilascia risorse in ordine inverso di acquisizione
if (buffer != NULL) {
free(buffer);
buffer = NULL; // Buona pratica
}
if (fd != -1) {
close(fd);
fd = -1; // Buona pratica
}
return result;
}
int main() {
if (process_file("data.txt") == 0) {
printf("File processato con successo\n");
} else {
printf("Errore nel processamento\n");
}
return 0;
}
Vantaggi del pattern goto cleanup:
Pattern 5: Retry con Exponential Backoff
Per operazioni che possono temporaneamente fallire (es. network):
#include <unistd.h>
#include <errno.h>
int retry_operation(int (*operation)(void), int max_retries) {
int retry_delay = 1; // Inizia con 1 secondo
for (int i = 0; i < max_retries; i++) {
int result = operation();
if (result == 0) {
// Successo!
return 0;
}
// Errore - decidiamo se ritentare
if (errno == EAGAIN || errno == EBUSY) {
// Errore temporaneo - riprova dopo delay
printf("Tentativo %d fallito, riprovo tra %d secondi...\n",
i + 1, retry_delay);
sleep(retry_delay);
// Exponential backoff: 1s, 2s, 4s, 8s, ...
retry_delay *= 2;
if (retry_delay > 60) {
retry_delay = 60; // Massimo 60 secondi
}
} else {
// Errore permanente - non ritentare
return -1;
}
}
// Superato max tentativi
fprintf(stderr, "Operazione fallita dopo %d tentativi\n", max_retries);
return -1;
}
Questa gestione completa e robusta degli errori è ciò che distingue codice production-quality da codice didattico o prototipale!
Una delle considerazioni più importanti quando si programma a livello di sistema è comprendere il costo delle system call. Molti programmatori, specialmente quelli che sono nuovi alla programmazione di sistema, trattano le system call come qualsiasi altra funzione, senza rendersi conto che ogni chiamata comporta un overhead significativo.
Ogni system call comporta un overhead dovuto a diverse operazioni che devono essere eseguite per passare dal codice dell’applicazione al kernel e ritorno:
Context Switch: Passaggio da user mode a kernel mode e viceversa
Questo è il costo più significativo di una system call. Vediamo nel dettaglio cosa succede:
Salvataggio/ripristino registri CPU: Il processore deve salvare lo stato completo del programma prima di passare al kernel. Questo include tutti i registri general-purpose (RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8-R15 su x86-64), i registri di stato (FLAGS), e il program counter (RIP). Sono decine di registri che devono essere scritti in memoria e poi riletti quando si ritorna. Ogni accesso alla memoria richiede tempo, e anche con le cache L1 veloci, questo accumula latenza.
Cambio page table: Il processore usa page table diverse per il kernel e per ogni processo utente. Quando si passa al kernel, il registro CR3 (su x86-64) deve essere aggiornato per puntare alla page table del kernel. Questo non solo richiede un’operazione privilegiata, ma causa anche effetti collaterali significativi nelle cache del processore.
Flush cache TLB (Translation Lookaside Buffer): Il TLB è una cache hardware che memorizza le traduzioni da indirizzi virtuali a indirizzi fisici per velocizzare l’accesso alla memoria. Quando cambiate page table (passando da user space a kernel space), gran parte del TLB deve essere invalidato perché contiene traduzioni che non sono più valide. Questo significa che le successive traduzioni di indirizzi saranno più lente fino a che il TLB non viene riempito nuovamente. Questo effetto è chiamato “TLB miss” e può rallentare significativamente il codice che esegue dopo il ritorno dalla system call.
Per dare un’idea dell’impatto: un accesso alla memoria con un “TLB hit” (traduzione già in cache) richiede pochi nanosecondi, mentre un “TLB miss” può richiedere centinaia di cicli di clock per camminare la page table in memoria.
Validazione: Il kernel deve validare tutti i parametri
Il kernel non può fidarsi di nulla che arriva dallo user space. Ogni puntatore, ogni dimensione, ogni flag deve essere controllato. Quando chiamate read(fd, buffer, size), il kernel deve:
fd sia un file descriptor valido e aperto da questo processobuffer punti a memoria allocata al processo e scrivibilesize non sia negativo o assurdamente grandefdTutti questi controlli richiedono accessi a strutture dati del kernel, lookup in tabelle, e potenzialmente page walk per verificare i permessi della memoria. Ognuna di queste operazioni richiede tempo.
Sincronizzazione: Potenziali lock nel kernel
Il kernel Linux è progettato per eseguire su sistemi multiprocessore con decine o centinaia di core. Questo significa che più thread potrebbero tentare di modificare le stesse strutture dati del kernel contemporaneamente. Per prevenire race condition e garantire la consistenza dei dati, il kernel usa lock (mutex, spinlock, read-write lock, etc.).
Quando chiamate una system call, potreste dover attendere che un lock diventi disponibile. Per esempio, se due processi tentano di scrivere nello stesso file contemporaneamente, il kernel usa un lock per serializzare le operazioni. Il primo processo ottiene il lock immediatamente, ma il secondo deve aspettare. Questo waiting time si aggiunge alla latenza della system call.
In sistemi con alta contesa (molti core che competono per le stesse risorse), il tempo speso ad aspettare lock può superare il tempo effettivamente speso ad eseguire il lavoro richiesto dalla system call.
Ordine di grandezza temporale:
Per mettere questi numeri in prospettiva, vediamo quanto tempo richiedono diverse operazioni:
System call “vuota” (getpid, che semplicemente ritorna il PID senza fare altro lavoro): ~100-300 nanosecondi su hardware moderno. Questo è il costo “base” di entrare e uscire dal kernel, senza fare alcun lavoro utile.
Chiamata a funzione normale: ~1-5 nanosecondi. Una chiamata a funzione in user space richiede solo pushare parametri sullo stack, saltare all’indirizzo della funzione, eseguire il codice, e ritornare. Tutto questo può essere fatto in pochi cicli di clock su processori moderni.
Rapporto: Una system call è circa 50-100 volte più lenta di una funzione normale. Questo fattore può variare: su processori più vecchi o su architetture embedded, può essere anche peggio (200-500 volte). Su processori molto moderni con ottimizzazioni per le syscall (come SYSCALL/SYSRET su x86-64 invece del vecchio INT 0x80), può essere leggermente meglio.
Implicazioni pratiche:
Questo overhead significa che se il vostro programma fa milioni di system call al secondo, state sprecando una frazione significativa del tempo della CPU solo nell’overhead di entrare e uscire dal kernel, senza fare lavoro utile.
Esempio concreto: immaginate di voler copiare un file di 1 GB:
// APPROCCIO PESSIMO - circa 1 miliardo di system call!
char byte;
while (read(in_fd, &byte, 1) > 0) { // Una syscall per ogni byte!
write(out_fd, &byte, 1); // Un'altra syscall per ogni byte!
}
// Tempo: potenzialmente minuti!
Ogni byte richiede due system call (una read e una write). Con overhead di 200ns per syscall, abbiamo 400ns per byte, che su 1GB significa 400 secondi (quasi 7 minuti) spesi SOLO nell’overhead, senza contare il tempo di I/O effettivo!
// APPROCCIO MIGLIORE - circa 250,000 system call
char buffer[4096];
ssize_t n;
while ((n = read(in_fd, buffer, sizeof(buffer))) > 0) {
write(out_fd, buffer, n);
}
// Tempo: secondi
Leggendo 4KB alla volta, riduciamo il numero di system call di un fattore 4096. Questo riduce l’overhead da 400 secondi a meno di 0.1 secondi – un miglioramento di 4000 volte!
Questa è l’importanza di comprendere il costo delle system call e di progettare il codice per minimizzarle.
Compreso il costo delle system call, vediamo ora le strategie principali per minimizzare il loro impatto sulle performance. Ogni strategia affronta il problema da un angolo diverso, e spesso la soluzione migliore è una combinazione di più approcci.
1. Buffering - Raggruppare Operazioni
Il buffering è probabilmente la tecnica più importante e più semplice per ridurre il numero di system call. L’idea fondamentale è: invece di fare tante piccole operazioni, raccogliete i dati in un buffer e fate una singola operazione grande.
Vediamo un esempio concreto:
// LENTO: Una system call per ogni byte - 1000 system call!
for (int i = 0; i < 1000; i++) {
write(fd, &data[i], 1); // Scrive 1 byte alla volta
}
// Tempo approssimativo:
// 1000 syscall × 200 nanosec = 200,000 nanosec = 0.2 millisecondi
// SOLO per l'overhead, senza contare l'I/O effettivo
In questo approccio, ogni iterazione del loop fa una system call per scrivere un singolo byte. Il kernel deve essere invocato 1000 volte, fare 1000 context switch, validare i parametri 1000 volte. La maggior parte del tempo è speso nell’overhead piuttosto che nel lavoro utile.
// VELOCE: Una sola system call!
write(fd, data, 1000); // Scrive 1000 byte in una volta
// Tempo approssimativo:
// 1 syscall × 200 nanosec = 200 nanosec
// Miglioramento: 1000× più veloce!
Con una singola chiamata, il kernel viene invocato una sola volta. Il kernel poi scrive tutti i 1000 byte internamente, che è molto più efficiente. Non solo risparmiamo 999 context switch, ma il kernel può anche ottimizzare l’I/O internamente (per esempio, usando DMA - Direct Memory Access - per trasferire grandi blocchi di dati senza coinvolgere la CPU).
Questo principio è alla base del buffering nelle librerie standard come stdio. Quando usate fprintf() o printf(), i dati non vengono scritti immediatamente sul file (o stdout). Vengono invece accumulati in un buffer interno. Solo quando:
fflush() esplicitamentefclose()viene effettivamente chiamata la system call write(). Questo può ridurre il numero di system call di migliaia di volte in programmi che stampano molto output.
2. Memory Mapping per File Grandi
Memory mapping (mmap) è una tecnica sofisticata che elimina completamente certe system call sostituendole con accessi diretti alla memoria.
// Approccio tradizionale con read/write
char buffer[4096];
while (read(fd, buffer, sizeof(buffer)) > 0) {
// Processa buffer
// Per ogni iterazione: 1 system call read()
}
// Se il file è 1GB, abbiamo ~260,000 system call
In questo approccio tradizionale, per leggere un file dovete ripetutamente chiamare read(). Ogni chiamata è una system call con tutto l’overhead che comporta. Inoltre, i dati vengono copiati dal kernel space allo user space (nel vostro buffer), che è un’altra operazione costosa.
// Approccio con memory mapping
char *mapped = mmap(NULL, file_size, PROT_READ,
MAP_PRIVATE, fd, 0);
// UNA SOLA system call per mappare il file!
// Ora possiamo accedere al file come se fosse un array in memoria
for (size_t i = 0; i < file_size; i++) {
char byte = mapped[i]; // Nessuna system call!
// Processa byte
}
// Totale system call: 1 (mmap) + 1 (munmap) = 2
Con mmap, mappate l’intero file (o una porzione) nello spazio di indirizzi del vostro processo. Da quel momento in poi, leggere dal file è semplice come leggere da un array – nessuna system call necessaria!
Come funziona sotto il cofano? Quando accedete a mapped[i] per la prima volta, il processore genera un “page fault” perché quella pagina di memoria non è ancora stata caricata in RAM. Il kernel gestisce questo page fault, legge la pagina corrispondente dal disco, la mappa nella memoria del processo, e riprende l’esecuzione. Ma questo succede automaticamente, senza che il vostro codice debba fare nulla, e soprattutto senza system call esplicite per ogni operazione di lettura.
Inoltre, il kernel può fare “prefetching” – se vede che state leggendo sequenzialmente, può precaricare le pagine successive in background, migliorando ulteriormente le performance.
Quando usare mmap:
Quando NON usare mmap:
3. Vectored I/O - Operazioni Scatter/Gather
Vectored I/O (o scatter/gather I/O) permette di leggere o scrivere da/verso multipli buffer in una singola system call.
// Situazione: avete dati in più buffer separati
char header[100];
char payload[1000];
char footer[50];
// Approccio tradizionale: 3 system call
write(fd, header, 100);
write(fd, payload, 1000);
write(fd, footer, 50);
// 3 system call, 3 context switch
Questo è un pattern comune: avete dati che logicamente formate parti di un messaggio ma sono memorizzati in buffer separati in memoria. Forse header e footer sono strutture fisse, mentre payload è dinamicamente allocato. Scriverli separatamente richiede tre system call.
// Approccio con writev: 1 sola system call!
struct iovec iov[3];
iov[0].iov_base = header;
iov[0].iov_len = 100;
iov[1].iov_base = payload;
iov[1].iov_len = 1000;
iov[2].iov_base = footer;
iov[2].iov_len = 50;
writev(fd, iov, 3);
// 1 system call, 1 context switch
// 3× più veloce per quanto riguarda l'overhead
writev() (e la corrispondente readv() per lettura) accetta un array di strutture iovec, ognuna che descrive un buffer. Il kernel legge tutti questi buffer in una singola operazione atomica, scrivendo i dati nell’ordine specificato.
Vantaggi:
Casi d’uso reali:
4. I/O Asincrono - Non Bloccare in Attesa
I/O asincrono (AIO) permette di iniziare operazioni di I/O senza bloccare il processo in attesa del completamento.
// I/O sincrono tradizionale:
ssize_t n = read(fd, buffer, size);
// Il processo si BLOCCA qui fino al completamento
// La CPU non può fare altro lavoro nel frattempo
Con I/O sincrono, quando chiamate read(), il vostro processo viene messo nello stato “blocked” dallo scheduler. Non può eseguire nulla fino a che i dati non sono disponibili. Se state leggendo da un disco lento o da una rete, questo potrebbe richiedere millisecondi (o anche secondi).
// I/O asincrono:
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = size;
aio_read(&cb); // Inizia la lettura, NON si blocca!
// Ora possiamo fare altro lavoro utile mentre I/O procede
do_other_work();
process_more_requests();
compute_something();
// Quando servono i dati, controlliamo se sono pronti
while (aio_error(&cb) == EINPROGRESS) {
// Ancora in corso, facciamo altro...
do_more_work();
}
// I/O completato, prendiamo il risultato
ssize_t n = aio_return(&cb);
Con AIO, aio_read() ritorna immediatamente dopo aver iniziato l’operazione. Il kernel continua a leggere i dati in background, mentre il vostro processo può continuare ad eseguire. Questo è particolarmente potente in applicazioni server che devono gestire molte connessioni contemporaneamente.
Benefici:
Svantaggi:
Modern alternative: Su Linux moderno, io_uring è un nuovo framework per I/O asincrono molto più efficiente di AIO POSIX. È usato da database ad alte performance come PostgreSQL e da application server che necessitano di massimo throughput.
In sintesi: la chiave per performance ottimali è minimizzare il numero di system call. Che sia attraverso buffering, memory mapping, vectored I/O, o I/O asincrono, l’obiettivo è sempre lo stesso: fare più lavoro con meno invocazioni del kernel.
strace è uno strumento essenziale per tracciare e analizzare le system call eseguite da un processo. È il tool più usato per:
Sintassi base e opzioni comuni:
# ===========================================
# USO BASILARE
# ===========================================
# Traccia tutte le system call di un comando
strace ls -l
# Output mostra ogni syscall:
# execve("/bin/ls", ["ls", "-l"], 0x7ffe8a... = 0
# brk(NULL) = 0x55a1a7...
# openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
# ...
# ===========================================
# FILTRAGGIO PER TIPO DI SYSCALL
# ===========================================
# Traccia solo system call specifiche
strace -e open,read,write ls
# Mostra solo open, read, write - ignora tutte le altre
# Traccia multiple syscall
strace -e open,openat,close,read,write cat file.txt
# Traccia categorie di syscall
strace -e trace=file ls # Solo syscall legate a file
strace -e trace=process ls # Solo syscall legate a processi
strace -e trace=network ls # Solo syscall di rete
strace -e trace=signal ls # Solo syscall di segnali
strace -e trace=ipc ls # Solo syscall IPC
strace -e trace=memory ls # Solo syscall di memoria
# Traccia tutto TRANNE alcune syscall
strace -e \!open,close ls # Tutto tranne open e close
# ===========================================
# TIMESTAMP E TIMING
# ===========================================
# Mostra timestamp assoluti
strace -t ls
# Output: 14:23:45 open("/etc/ld.so.cache", O_RDONLY) = 3
# Timestamp con microsecondi
strace -tt ls
# Output: 14:23:45.123456 open(...) = 3
# Timestamp relativi (dall'inizio)
strace -r ls
# Output: 0.000123 open(...) = 3
# 0.000045 read(...) = 832
# Mostra durata di ogni syscall
strace -T ls
# Output: open("/etc/ld.so.cache", ...) = 3 <0.000123>
# ^^^^^^^^ tempo in secondi
# ===========================================
# STATISTICHE E PROFILING
# ===========================================
# Mostra statistiche aggregate (molto utile!)
strace -c ls
# Output:
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 52.34 0.014553 7 2016 getdents
# 19.45 0.005407 13 397 newfstatat
# 10.23 0.002843 23 120 openat
# 8.76 0.002436 20 120 close
# 5.12 0.001423 11 120 fstat
# ...
# ------ ----------- ----------- --------- --------- ----------------
# 100.00 0.027802 2893 177 total
# Combinazione: traccia E statistiche
strace -c -T ls
# ===========================================
# TRACCIARE PROCESSI ESISTENTI
# ===========================================
# Attacca a un processo già in esecuzione (richiede permessi)
strace -p <PID>
# Esempio: traccia un server web in esecuzione
strace -p 1234
# Per staccare: Ctrl+C
# Traccia processo e mostra PID di ogni syscall
strace -f -p 1234 # -f segue i fork
# ===========================================
# SEGUIRE FORK E THREAD
# ===========================================
# Segue anche i processi figli (fork)
strace -f ./programma
# Mostra PID prima di ogni syscall
strace -ff ./programma
# Output: [pid 12345] open(...) = 3
# [pid 12346] read(...) = 100
# Crea file separati per ogni processo
strace -ff -o output ./programma
# Crea: output.12345, output.12346, etc.
# ===========================================
# OUTPUT E LOGGING
# ===========================================
# Scrivi output su file invece di stderr
strace -o trace.log ls
# Append invece di sovrascrivere
strace -o trace.log -A ls
# Output più leggibile con indentazione
strace -i ls # Mostra instruction pointer
# Stringhe più lunghe (default 32 char)
strace -s 128 ls # Mostra 128 caratteri per stringa
# Mostra contenuto buffer in formato esadecimale
strace -x ls
# ===========================================
# DEBUG AVANZATO
# ===========================================
# Mostra valori degli argomenti
strace -v ls # Verbose - mostra tutti i campi delle struct
# Decodifica socket e network
strace -e trace=network curl https://example.com
# Mostra numeri syscall grezzi
strace -n ls
# ===========================================
# TROUBLESHOOTING SPECIFICO
# ===========================================
# Perché il programma è lento?
strace -c -T ./programma_lento
# Guarda la colonna "time" per trovare syscall costose
# Dove cerca file la mia app?
strace -e openat ./myapp
# Mostra tutti i tentativi di apertura file
# Perché la connessione fallisce?
strace -e trace=network ./client
# Mostra connect, socket, send, recv
# Il programma fa troppe syscall?
strace -c ./programma
# Se vedi migliaia di chiamate piccole, c'è problema di buffering
Esempio pratico completo di debugging con strace:
Problema: Un programma è lento all’avvio. Usiamo strace per investigare.
# Passo 1: Traccia con timestamp e durata
$ strace -tt -T ./slow_program 2>&1 | head -50
14:23:45.123456 execve("./slow_program", ...) = 0 <0.000234>
14:23:45.124001 brk(NULL) = 0x55a1a7000000 <0.000011>
14:23:45.124089 openat(AT_FDCWD, "/etc/ld.so.cache", ...) = 3 <0.000234>
14:23:45.124456 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", ...) = 3 <0.000123>
...
14:23:45.567234 openat(AT_FDCWD, "/home/user/.config/app/config.txt", ...) = -1 ENOENT <0.000045>
14:23:45.567345 openat(AT_FDCWD, "/home/user/.config/app/default.txt", ...) = -1 ENOENT <0.000043>
14:23:45.567421 openat(AT_FDCWD, "/etc/app/config.txt", ...) = -1 ENOENT <0.000041>
14:23:45.567498 openat(AT_FDCWD, "/etc/app/default.txt", ...) = 3 <0.000123>
14:23:46.789123 read(3, "config data...", 4096) = 234 <1.221234>
^^^^^^^^ PROBLEMA QUI!
...
# Passo 2: Statistiche per confermare
$ strace -c ./slow_program
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
95.23 1.234567 1234567 1 read
2.34 0.030123 45 670 openat
...
# DIAGNOSI:
# - Molti tentativi di apertura file falliti (overhead)
# - Una read() impiega 1.22 secondi (file su network mount lento?)
# - SOLUZIONE: Cache la config, evita network mount, o ottimizza I/O
Interpretare l’output di strace:
# Anatomia di una riga di strace:
openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3 <0.000234>
│ │ │ │ │ └─ Tempo impiegato
│ │ │ │ └─ Valore di ritorno
│ │ │ └─ Terzo parametro (flags)
│ │ └─ Secondo parametro (pathname)
│ └─ Primo parametro (dirfd)
└─ Nome system call
# Chiamate fallite:
open("/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)
│ └─ Codice errno e descrizione
└─ Ritorno -1 indica errore
# Parametri complessi (struct):
stat("/etc/passwd", {st_mode=S_IFREG|0644, st_size=1234, ...}) = 0
└─ Contenuto della struct stat
# Segnali:
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
└─ Indica che il processo ha ricevuto SIGINT
# Interruzioni:
read(3, <unfinished ...>
<... read resumed>"data", 100) = 100
└─ System call interrotta (es. da altro thread), poi ripresa
Casi d’uso reali di strace:
1. Programma che non trova file:
$ strace -e openat ./myapp 2>&1 | grep -i config
openat(AT_FDCWD, "/home/user/.config/myapp.conf", ...) = -1 ENOENT
openat(AT_FDCWD, "/etc/myapp.conf", ...) = 3
# SOLUZIONE: File deve essere in /etc/myapp.conf
2. Programma che non si connette:
$ strace -e trace=network ./client 2>&1 | grep connect
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(8080),
sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED
# SOLUZIONE: Nessun server in ascolto su localhost:8080
3. Programma che impazzisce in loop:
$ strace -c ./looping_program
# Ctrl+C dopo qualche secondo
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.99 5.234567 0 10234567 write
# SOLUZIONE: 10 milioni di write! C'è un loop che scrive continuamente
4. Permission denied ma non si capisce dove:
$ strace -e openat,access ./myapp 2>&1 | grep EACCES
access("/secret/file", R_OK) = -1 EACCES (Permission denied)
# SOLUZIONE: Il file /secret/file non è leggibile
5. Performance analysis:
# Conta le syscall per secondo
$ strace -c -f ./web_server &
$ # ... genera traffico ...
$ kill $!
# Se vedi molte piccole read/write, il buffering è insufficiente
# Se vedi molte stat/openat, c'è thrashing su filesystem
Limitazioni di strace:
Alternative e tool complementari:
# ltrace - Traccia chiamate a librerie (non syscall)
ltrace ./program
# perf - Performance profiler più leggero
perf trace ./program
perf stat -e 'syscalls:*' ./program
# ftrace - Tracing a livello kernel (più potente ma più complesso)
# eBPF/BCC - Tracing programmabile e performante
Script utile per analisi strace:
#!/bin/bash
# analyze_strace.sh - Analizza output di strace
echo "=== Top 10 syscall più frequenti ==="
grep -oP '^[^(]+' strace.log | sort | uniq -c | sort -rn | head -10
echo -e "\n=== Errori più frequenti ==="
grep '= -1' strace.log | grep -oP 'E[A-Z]+' | sort | uniq -c | sort -rn
echo -e "\n=== File non trovati ==="
grep 'ENOENT' strace.log | grep -oP '"[^"]+"' | sort -u
echo -e "\n=== Syscall più lente (>0.1s) ==="
grep -P '<0\.[1-9]|<[1-9]' strace.log | head -20
strace è uno strumento indispensabile nella toolbox di ogni programmatore di sistema!
Simile a strace ma traccia chiamate a librerie (non system call):
ltrace ls
# Conta system call
perf stat -e 'syscalls:sys_enter_*' ls
# Registra e analizza
perf record -e 'syscalls:*' ls
perf report
Strumenti avanzati per tracing e profiling a livello kernel.
Esempio SystemTap:
# Script che conta system call per processo
stap -e 'probe syscall.* {
counts[execname()] <<< 1
}
probe end {
foreach (name in counts+)
printf("%s: %d\n", name, @count(counts[name]))
}'
Linux implementa lo standard POSIX ma aggiunge system call specifiche:
System Call Solo Linux:
// Linux-specific
epoll_create() // I/O multiplexing efficiente
epoll_wait()
inotify_init() // File system monitoring
inotify_add_watch()
eventfd() // Event notification
signalfd() // Signal handling via file descriptor
timerfd_create() // Timer via file descriptor
splice() // Zero-copy pipe operations
tee()
sendfile() // Zero-copy file sending
Windows usa un modello completamente diverso:
Confronto:
| Linux/Unix | Windows | Funzione |
|---|---|---|
fork() |
CreateProcess() |
Crea processo |
open() |
CreateFile() |
Apre file |
read(), write() |
ReadFile(), WriteFile() |
I/O file |
mmap() |
CreateFileMapping() |
Memory mapping |
socket() |
WSASocket() |
Crea socket |
fork() + exec() |
Non esiste equivalente diretto | Pattern Unix |
Windows non ha fork() - usa direttamente CreateProcess() che combina creazione processo ed esecuzione programma.
macOS (basato su Darwin/XNU) è POSIX-compliant con alcune estensioni:
// macOS-specific
kqueue() // Event notification (simile a epoll)
kevent()
La libc fornisce wrapper attorno alle system call:
Esempio: printf() vs write()
// Alto livello: printf (libc)
printf("Hello, world!\n");
// Basso livello: write (system call diretta)
write(STDOUT_FILENO, "Hello, world!\n", 14);
printf() internamente:
write() quando il buffer è pienoVantaggi wrapper libc:
Quando usare system call dirette:
#include <stdio.h>
FILE *fp = fopen("file.txt", "w");
// Buffered: non chiama subito write()
fprintf(fp, "Line 1\n"); // Dati nel buffer
fprintf(fp, "Line 2\n"); // Dati nel buffer
fprintf(fp, "Line 3\n"); // Dati nel buffer
// Forza flush (system call write)
fflush(fp);
// La chiusura fa automaticamente flush
fclose(fp);
Tipi di buffering:
// No buffering: ogni operazione -> system call immediata
setbuf(fp, NULL);
// Line buffering: flush su newline
setvbuf(fp, NULL, _IOLBF, 0);
// Full buffering: flush quando buffer pieno
setvbuf(fp, NULL, _IOFBF, BUFSIZ);
| Aspetto | System Call | Function Call |
|---|---|---|
| Esecuzione | Kernel mode | User mode |
| Overhead | Alto (~100-300ns) | Basso (~1-5ns) |
| Context switch | Sì | No |
| Accesso hardware | Sì | No |
| Sicurezza | Validazione kernel | Nessuna validazione |
| Portabilità | Standard POSIX | Dipende |
| Esempi | read(), write(), fork() |
strlen(), strcpy(), malloc() |
Usa system call dirette quando:
Necessità di controllo fine
// Controllo esatto su buffering
write(fd, data, size); // Scrive immediatamente
Performance critiche
// Zero-copy con sendfile
sendfile(out_fd, in_fd, NULL, size);
Funzionalità non wrapped
// epoll non ha wrapper standard
epoll_create1(EPOLL_CLOEXEC);
Programming di sistema
// Creazione demoni, gestione processi, ecc.
if (fork() == 0) {
setsid(); // Crea nuova sessione
// ...
}
Usa funzioni di libreria quando:
Scrivere codice che usa system call in modo corretto ed efficiente richiede attenzione a molti dettagli che spesso vengono trascurati da programmatori inesperti. Qui esaminiamo le pratiche fondamentali che ogni programmatore di sistema dovrebbe seguire.
Questa è forse la regola più importante e più violata. Ogni system call può fallire, e ignorare gli errori porta a bug subdoli e difficili da tracciare.
// ❌ SBAGLIATO - Codice pericoloso e inaffidabile
int fd = open(filename, O_RDONLY);
read(fd, buffer, size); // Se open è fallito, fd è -1!
Cosa c’è di sbagliato in questo codice? Se open() fallisce (il file non esiste, permessi insufficienti, troppi file aperti, etc.), ritorna -1. Il codice poi passa -1 a read(), che è un file descriptor completamente invalido. La read() fallirà certamente, ma il programma continua ignaro. I dati in buffer resteranno non inizializzati (spazzatura casuale), e il programma potrebbe usare questi dati corrotti per prendere decisioni o fare calcoli, portando a risultati completamente errati.
Peggio ancora, il programma potrebbe sembrare funzionare “nella maggior parte dei casi” (quando open ha successo), fallendo solo occasionalmente in modi difficili da riprodurre. Questo tipo di bug è un incubo da debuggare in produzione.
// ✅ CORRETTO - Controllo errori appropriato
int fd = open(filename, O_RDONLY);
if (fd == -1) {
// open() ha fallito - gestiamo l'errore appropriatamente
perror("open"); // Stampa messaggio di errore descrittivo
exit(EXIT_FAILURE); // Termina con codice di errore
}
// Ora sappiamo con certezza che fd è valido
ssize_t n = read(fd, buffer, size);
if (n == -1) {
// read() ha fallito - anche questo va gestito
perror("read");
close(fd); // Cleanup: chiudi il file prima di uscire
exit(EXIT_FAILURE);
}
// Ora sappiamo che abbiamo letto n byte validi
In questa versione corretta:
open() prima di usare fdopen() fallisce, stampiamo un messaggio di errore informativo (che include sia il nome della funzione che la descrizione dell’errore da errno)read()read(), chiudiamo il file descriptor (cleanup delle risorse)Perché questo è così critico:
EINTR (Interrupted system call) è un errore speciale che merita attenzione particolare. Molte system call “bloccanti” possono essere interrotte da segnali.
Cosa significa “bloccante”? Una system call bloccante è una che può mettere il processo in stato di sleep, aspettando qualche evento. Per esempio:
read() da un file su disco può bloccare aspettando che il disco legga i datiread() da un socket può bloccare aspettando che arrivino dati dalla retewait() blocca aspettando che un processo figlio terminisleep() blocca per un periodo di tempo specificatoOra, cosa succede se mentre il vostro processo è bloccato in una di queste system call, arriva un segnale (per esempio SIGINT da Ctrl+C, o SIGALRM da un timer, o SIGCHLD quando un figlio termina)?
Il kernel deve svegliare il vostro processo per consegnare il segnale. L’handler del segnale viene eseguito, e quando ritorna… cosa dovrebbe fare la system call originale? Potrebbe continuare ad aspettare, ma questo comportamento può essere problematico. Per default, molte system call vengono invece interrotte e ritornano l’errore EINTR.
// ⚠️ Versione fragile - non gestisce EINTR
ssize_t n = read(fd, buffer, size);
if (n == -1) {
perror("read"); // Potrebbe stampare "read: Interrupted system call"
return -1; // Trattiamo come errore reale, ma è solo un'interruzione!
}
In questo codice, se read() viene interrotta da un segnale, trattiamo l’interruzione come un errore fatale. Ma EINTR non è realmente un errore – significa solo “riprova”. I dati non sono stati letti perché siete stati interrotti, ma potreste provare di nuovo.
// ✅ Versione robusta - gestisce EINTR correttamente
ssize_t safe_read(int fd, void *buf, size_t count) {
ssize_t n;
// Loop: continua a provare finché non hai successo o un errore reale
do {
n = read(fd, buf, count);
// Se n == -1 E errno == EINTR:
// La read è stata interrotta da un segnale
// Riprova immediatamente (il loop continua)
// Se n == -1 E errno != EINTR:
// Errore reale, esci dal loop e ritorna -1
// Se n >= 0:
// Successo, esci dal loop e ritorna n
} while (n == -1 && errno == EINTR);
return n; // Ritorna numero byte letti o -1 per errore reale
}
Questo wrapper “safe_read” gestisce EINTR automaticamente. Il loop do-while continua a chiamare read() finché non ha successo o non riceve un errore diverso da EINTR.
Nota importante: Non tutte le system call ritornano EINTR. Alcune sono “automatically restarted” dal kernel se avete impostato il flag SA_RESTART quando avete installato l’handler del segnale. Ma non potete fare affidamento su questo in modo portabile, quindi è meglio gestire EINTR esplicitamente.
Dovreste creare wrapper simili per altre system call bloccanti:
ssize_t safe_write(int fd, const void *buf, size_t count) {
ssize_t n;
do {
n = write(fd, buf, count);
} while (n == -1 && errno == EINTR);
return n;
}
pid_t safe_wait(int *status) {
pid_t pid;
do {
pid = wait(status);
} while (pid == -1 && errno == EINTR);
return pid;
}
pid_t safe_waitpid(pid_t pid, int *status, int options) {
pid_t result;
do {
result = waitpid(pid, status, options);
} while (result == -1 && errno == EINTR);
return result;
}
I file descriptor, la memoria allocata, i lock – tutte queste sono risorse limitate che devono essere rilasciate quando non servono più. I “resource leak” (perdite di risorse) sono bug comuni che causano problemi seri in programmi long-running.
Cosa succede se non chiudete i file descriptor? Ogni processo ha un limite sul numero di file che può avere aperti contemporaneamente (tipicamente 1024 per default, controllabile con ulimit -n). Se continuate ad aprire file senza chiuderli, eventualmente raggiungerete questo limite e le successive chiamate a open() falliranno con EMFILE (Too many open files).
// ❌ Versione con potenziale resource leak
int process_file(const char *filename) {
int fd = -1;
void *buffer = NULL;
int result = -1;
// Tentativo apertura file
fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open");
return -1; // Ok, nessuna risorsa allocata
}
// Tentativo allocazione buffer
buffer = malloc(BUFFER_SIZE);
if (buffer == NULL) {
fprintf(stderr, "malloc fallita\n");
// ⚠️ BUG: fd è aperto ma non lo chiudiamo!
return -1; // Resource leak!
}
// Tentativo lettura
ssize_t n = read(fd, buffer, BUFFER_SIZE);
if (n == -1) {
perror("read");
// ⚠️ BUG: non liberiamo buffer né chiudiamo fd!
return -1; // Resource leak doppio!
}
// Elaborazione...
// Cleanup solo se arriviamo qui
free(buffer);
close(fd);
return 0;
}
In questo codice, se malloc fallisce, usciamo senza chiudere fd. Se read() fallisce, perdiamo sia buffer che fd. In un programma che chiama questa funzione migliaia di volte, questi leak si accumulano fino a esaurire le risorse del sistema.
// ✅ Versione corretta con pattern goto cleanup
int process_file(const char *filename) {
int fd = -1;
void *buffer = NULL;
int result = -1; // Assume fallimento per default
// === Acquisizione risorse ===
fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open");
goto cleanup; // Salta al cleanup (niente da liberare)
}
buffer = malloc(BUFFER_SIZE);
if (buffer == NULL) {
fprintf(stderr, "malloc fallita\n");
goto cleanup; // Cleanup chiuderà fd
}
// === Operazioni ===
ssize_t n = read(fd, buffer, BUFFER_SIZE);
if (n == -1) {
perror("read");
goto cleanup; // Cleanup libererà buffer e chiuderà fd
}
// Elaborazione dati...
process_data(buffer, n);
result = 0; // Successo!
// === Cleanup: eseguito SEMPRE ===
cleanup:
// Libera risorse in ordine inverso di acquisizione
if (buffer != NULL) {
free(buffer);
buffer = NULL; // Buona pratica: azzera dopo free
}
if (fd != -1) {
if (close(fd) == -1) {
perror("close"); // Anche close può fallire!
}
fd = -1; // Buona pratica: azzera dopo close
}
return result;
}
Il pattern goto cleanup garantisce che le risorse vengano sempre liberate, indipendentemente da quale punto del codice si esce. Questo approccio:
Dettagli importanti:
Ordine inverso: Liberate le risorse nell’ordine inverso rispetto a quello di acquisizione. Se A dipende da B, acquisite B poi A, e liberate A poi B.
Check NULL/invalid: Prima di liberare, controllate che la risorsa sia stata effettivamente acquisita. free(NULL) è safe (non fa nulla), ma close(-1) ritorna errore (anche se innocuo).
Azzeramento: Dopo aver liberato una risorsa, impostate il puntatore/descriptor a NULL/-1. Questo previene “use after free” e “double free”, due bug pericolosissimi.
Error checking anche in cleanup: Anche close() può fallire (es. su NFS, potrebbe fallire se c’è un errore di rete nel flush finale). In generale dovreste controllare, anche se spesso non c’è molto da fare se cleanup fallisce.
Questo pattern può sembrare verboso, ma è lo standard in codice C production-quality. Progetti come il kernel Linux lo usano estensivamente.
// NON atomico: race condition
if (access(filename, F_OK) == -1) {
// File non esiste
int fd = open(filename, O_CREAT | O_WRONLY, 0644);
// Ma potrebbe essere stato creato nel frattempo!
}
// Atomico: usa O_EXCL
int fd = open(filename, O_CREAT | O_EXCL | O_WRONLY, 0644);
if (fd == -1) {
if (errno == EEXIST) {
// File già esistente
}
}
// Inefficiente per file grandi
char buf[4096];
while ((n = read(fd, buf, sizeof(buf))) > 0) {
write(out_fd, buf, n);
}
// Più efficiente: sendfile (zero-copy)
sendfile(out_fd, fd, NULL, file_size);
// Oppure: mmap per accesso casuale
char *data = mmap(NULL, file_size, PROT_READ,
MAP_PRIVATE, fd, 0);
// SBAGLIATO
write(fd, buffer, size); // Potrebbe scrivere meno di size!
// CORRETTO
size_t total_written = 0;
while (total_written < size) {
ssize_t n = write(fd, buffer + total_written,
size - total_written);
if (n == -1) {
if (errno == EINTR) continue;
perror("write");
return -1;
}
total_written += n;
}
// Vecchio stile
int fd = open(filename, O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
// Moderno: atomico
int fd = open(filename, O_RDONLY | O_CLOEXEC);
Il kernel valida tutti i parametri, ma alcune validazioni devono essere fatte dall’applicazione:
// Controlla path relativi
const char *safe_open(const char *base_dir, const char *filename) {
// Previeni path traversal
if (strstr(filename, "..") != NULL) {
errno = EINVAL;
return NULL;
}
char fullpath[PATH_MAX];
snprintf(fullpath, sizeof(fullpath), "%s/%s", base_dir, filename);
return realpath(fullpath, NULL);
}
Time-of-Check to Time-of-Use bugs:
// VULNERABILE: TOCTOU race condition
if (access(filename, W_OK) == 0) {
// File scrivibile al momento del check
int fd = open(filename, O_WRONLY);
// Ma potrebbe essere cambiato nel frattempo!
write(fd, data, size);
}
// SICURO: tenta direttamente
int fd = open(filename, O_WRONLY);
if (fd == -1) {
if (errno == EACCES) {
// Gestisci errore permessi
}
} else {
write(fd, data, size);
}
Programmi con bit setuid richiedono attenzione speciale:
// Controlla UID reale vs effettivo
uid_t real_uid = getuid();
uid_t effective_uid = geteuid();
if (real_uid != effective_uid) {
// Running setuid - extra cautela
// Valida environment
clearenv();
// Imposta PATH sicuro
setenv("PATH", "/bin:/usr/bin", 1);
// Valida rigorosamente tutti input
}
Linux permette di limitare le system call disponibili:
#include <sys/prctl.h>
#include <linux/seccomp.h>
// Abilita strict seccomp: solo read, write, exit, sigreturn
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);
// Ora qualsiasi altra system call terminerà il processo
1. Logging delle System Call
#define SYSCALL_LOG(call, ...) do { \
fprintf(stderr, "[SYSCALL] %s:%d: " #call "\n", \
__FILE__, __LINE__); \
call(__VA_ARGS__); \
} while(0)
SYSCALL_LOG(open, "/etc/passwd", O_RDONLY);
2. Verifica Errori Sistematicamente
#define CHECK_SYSCALL(call) do { \
if ((call) == -1) { \
fprintf(stderr, "[ERROR] %s:%d: %s failed: %s\n", \
__FILE__, __LINE__, #call, strerror(errno)); \
exit(EXIT_FAILURE); \
} \
} while(0)
int fd;
CHECK_SYSCALL(fd = open("/etc/passwd", O_RDONLY));
3. Wrapper con Timeout
// Esempio: read con timeout
ssize_t read_with_timeout(int fd, void *buf, size_t count,
int timeout_sec) {
fd_set readfds;
struct timeval tv;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
tv.tv_sec = timeout_sec;
tv.tv_usec = 0;
int ready = select(fd + 1, &readfds, NULL, NULL, &tv);
if (ready == -1) {
return -1; // Errore
} else if (ready == 0) {
errno = ETIMEDOUT;
return -1; // Timeout
}
return read(fd, buf, count);
}
1. strace - già discusso sopra
2. ltrace - Library calls
ltrace -c ./programma # Statistiche
ltrace -f ./programma # Segui fork
3. gdb - Debugging
gdb ./programma
(gdb) catch syscall open
(gdb) run
# Si ferma ad ogni open()
4. valgrind - Memory checking
valgrind --leak-check=full ./programma
5. perf - Performance analysis
perf stat ./programma
perf record -e syscalls:* ./programma
perf report
Implementare un programma che copi un file usando solo system call (open, read, write, close).
Requisiti:
Creare un programma che monitorizzi un processo usando system call.
Funzionalità:
/proc)Implementare una shell minimale che supporti:
fork + exec)dup2)pipe)&)Usare inotify (Linux) per monitorare modifiche a file/directory.
Eventi da tracciare:
Implementare un server TCP echo usando:
socket, bind, listen, acceptfork per gestire client multiplisyslogProgramma che usa mmap per:
Implementare un sistema di timer usando:
timer_create, timer_settimealarm e setitimertimerfd_create (Linux)Creare un mini-tracer che usa ptrace per:
Programma che esplora e modifica limiti di risorse:
getrlimit, setrlimitSIGXCPU, SIGXFSZSistema di comunicazione complesso usando:
# System call specifiche
man 2 read
man 2 write
man 2 open
man 2 fork
# Concetti generali
man 7 signal
man 7 socket
man 7 pipe
# Funzioni libreria C
man 3 printf
man 3 malloc
“Advanced Programming in the UNIX Environment” - Stevens & Rago
“The Linux Programming Interface” - Michael Kerrisk
“Linux System Programming” - Robert Love
“Understanding the Linux Kernel” - Bovet & Cesati
Le system call rappresentano l’interfaccia fondamentale tra applicazioni e sistema operativo. La loro comprensione è essenziale per:
Concetti chiave da ricordare:
Prospettive future:
La padronanza delle system call vi permetterà di sviluppare software di sistema robusto, efficiente e sicuro, comprendendo a fondo come le applicazioni interagiscono con il sistema operativo sottostante.
Questa lezione è stata preparata per il corso di Sistemi e Reti dell’Università Marconi Verona.